([]);
+ const [applyTags, setApplyTags] = useState('add');
+
+ const indexersTags = useMemo(() => {
+ const tags = ids.reduce((acc: number[], id) => {
+ const s = allIndexers.items.find((s: Indexer) => s.id === id);
+
+ if (s) {
+ acc.push(...s.tags);
+ }
+
+ return acc;
+ }, []);
+
+ return uniq(tags);
+ }, [ids, allIndexers]);
+
+ const onTagsChange = useCallback(
+ ({ value }: { value: number[] }) => {
+ setTags(value);
+ },
+ [setTags]
+ );
+
+ const onApplyTagsChange = useCallback(
+ ({ value }: { value: string }) => {
+ setApplyTags(value);
+ },
+ [setApplyTags]
+ );
+
+ const onApplyPress = useCallback(() => {
+ onApplyTagsPress(tags, applyTags);
+ }, [tags, applyTags, onApplyTagsPress]);
+
+ const applyTagsOptions = [
+ {
+ key: 'add',
+ get value() {
+ return translate('Add');
+ },
+ },
+ {
+ key: 'remove',
+ get value() {
+ return translate('Remove');
+ },
+ },
+ {
+ key: 'replace',
+ get value() {
+ return translate('Replace');
+ },
+ },
+ ];
+
+ return (
+
+ {translate('Tags')}
+
+
+
+
+
+
+ {translate('Cancel')}
+
+
+ {translate('Apply')}
+
+
+
+ );
+}
+
+export default TagsModalContent;
diff --git a/frontend/src/Settings/Indexers/Options/IndexerOptions.js b/frontend/src/Settings/Indexers/Options/IndexerOptions.js
new file mode 100644
index 000000000..018b8ce1d
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Options/IndexerOptions.js
@@ -0,0 +1,123 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Alert from 'Components/Alert';
+import FieldSet from 'Components/FieldSet';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import { inputTypes, kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+
+function IndexerOptions(props) {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ onInputChange
+ } = props;
+
+ return (
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+
+ {translate('UnableToLoadIndexerOptions')}
+
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+ }
+
+ );
+}
+
+IndexerOptions.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default IndexerOptions;
diff --git a/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js b/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js
new file mode 100644
index 000000000..4e8031a99
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js
@@ -0,0 +1,101 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import { fetchIndexerOptions, saveIndexerOptions, setIndexerOptionsValue } from 'Store/Actions/settingsActions';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import IndexerOptions from './IndexerOptions';
+
+const SECTION = 'indexerOptions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createSettingsSectionSelector(SECTION),
+ (advancedSettings, sectionSettings) => {
+ return {
+ advancedSettings,
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchIndexerOptions: fetchIndexerOptions,
+ dispatchSetIndexerOptionsValue: setIndexerOptionsValue,
+ dispatchSaveIndexerOptions: saveIndexerOptions,
+ dispatchClearPendingChanges: clearPendingChanges
+};
+
+class IndexerOptionsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ dispatchFetchIndexerOptions,
+ dispatchSaveIndexerOptions,
+ onChildMounted
+ } = this.props;
+
+ dispatchFetchIndexerOptions();
+ onChildMounted(dispatchSaveIndexerOptions);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ hasPendingChanges,
+ isSaving,
+ onChildStateChange
+ } = this.props;
+
+ if (
+ prevProps.isSaving !== isSaving ||
+ prevProps.hasPendingChanges !== hasPendingChanges
+ ) {
+ onChildStateChange({
+ isSaving,
+ hasPendingChanges
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.dispatchClearPendingChanges({ section: 'settings.indexerOptions' });
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.dispatchSetIndexerOptionsValue({ name, value });
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+IndexerOptionsConnector.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ hasPendingChanges: PropTypes.bool.isRequired,
+ dispatchFetchIndexerOptions: PropTypes.func.isRequired,
+ dispatchSetIndexerOptionsValue: PropTypes.func.isRequired,
+ dispatchSaveIndexerOptions: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired,
+ onChildMounted: PropTypes.func.isRequired,
+ onChildStateChange: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(IndexerOptionsConnector);
diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js
new file mode 100644
index 000000000..627263fff
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/MediaManagement.js
@@ -0,0 +1,508 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Alert from 'Components/Alert';
+import FieldSet from 'Components/FieldSet';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import { inputTypes, kinds, sizes } from 'Helpers/Props';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import translate from 'Utilities/String/translate';
+import NamingConnector from './Naming/NamingConnector';
+import RootFoldersConnector from './RootFolder/RootFoldersConnector';
+
+const rescanAfterRefreshOptions = [
+ {
+ key: 'always',
+ get value() {
+ return translate('Always');
+ }
+ },
+ {
+ key: 'afterManual',
+ get value() {
+ return translate('AfterManualRefresh');
+ }
+ },
+ {
+ key: 'never',
+ get value() {
+ return translate('Never');
+ }
+ }
+];
+
+const allowFingerprintingOptions = [
+ {
+ key: 'allFiles',
+ get value() {
+ return translate('Always');
+ }
+ },
+ {
+ key: 'newFiles',
+ get value() {
+ return translate('ForNewImportsOnly');
+ }
+ },
+ {
+ key: 'never',
+ get value() {
+ return translate('Never');
+ }
+ }
+];
+
+const downloadPropersAndRepacksOptions = [
+ {
+ key: 'preferAndUpgrade',
+ get value() {
+ return translate('PreferAndUpgrade');
+ }
+ },
+ {
+ key: 'doNotUpgrade',
+ get value() {
+ return translate('DoNotUpgradeAutomatically');
+ }
+ },
+ {
+ key: 'doNotPrefer',
+ get value() {
+ return translate('DoNotPrefer');
+ }
+ }
+];
+
+const fileDateOptions = [
+ {
+ key: 'none',
+ get value() {
+ return translate('None');
+ }
+ },
+ {
+ key: 'albumReleaseDate',
+ get value() {
+ return translate('AlbumReleaseDate');
+ }
+ }
+];
+
+class MediaManagement extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ isWindows,
+ onInputChange,
+ onSavePress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+ {
+ isFetching &&
+
+
+
+ }
+
+ {
+ !isFetching && error &&
+
+
+ {translate('UnableToLoadMediaManagementSettings')}
+
+
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+ }
+
+
+ );
+ }
+
+}
+
+MediaManagement.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ isWindows: PropTypes.bool.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default MediaManagement;
diff --git a/frontend/src/Settings/MediaManagement/MediaManagementConnector.js b/frontend/src/Settings/MediaManagement/MediaManagementConnector.js
new file mode 100644
index 000000000..9d6f959b8
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/MediaManagementConnector.js
@@ -0,0 +1,86 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import { fetchMediaManagementSettings, saveMediaManagementSettings, saveNamingSettings, setMediaManagementSettingsValue } from 'Store/Actions/settingsActions';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
+import MediaManagement from './MediaManagement';
+
+const SECTION = 'mediaManagement';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ (state) => state.settings.naming,
+ createSettingsSectionSelector(SECTION),
+ createSystemStatusSelector(),
+ (advancedSettings, namingSettings, sectionSettings, systemStatus) => {
+ return {
+ advancedSettings,
+ ...sectionSettings,
+ hasPendingChanges: !_.isEmpty(namingSettings.pendingChanges) || sectionSettings.hasPendingChanges,
+ isWindows: systemStatus.isWindows
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchMediaManagementSettings,
+ setMediaManagementSettingsValue,
+ saveMediaManagementSettings,
+ saveNamingSettings,
+ clearPendingChanges
+};
+
+class MediaManagementConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchMediaManagementSettings();
+ }
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: `settings.${SECTION}` });
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setMediaManagementSettingsValue({ name, value });
+ };
+
+ onSavePress = () => {
+ this.props.saveMediaManagementSettings();
+ this.props.saveNamingSettings();
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MediaManagementConnector.propTypes = {
+ fetchMediaManagementSettings: PropTypes.func.isRequired,
+ setMediaManagementSettingsValue: PropTypes.func.isRequired,
+ saveMediaManagementSettings: PropTypes.func.isRequired,
+ saveNamingSettings: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(MediaManagementConnector);
diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.css b/frontend/src/Settings/MediaManagement/Naming/Naming.css
new file mode 100644
index 000000000..59d223e92
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/Naming.css
@@ -0,0 +1,5 @@
+.namingInput {
+ composes: input from '~Components/Form/TextInput.css';
+
+ font-family: $monoSpaceFontFamily;
+}
diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.css.d.ts b/frontend/src/Settings/MediaManagement/Naming/Naming.css.d.ts
new file mode 100644
index 000000000..eed487132
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/Naming.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'namingInput': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.js b/frontend/src/Settings/MediaManagement/Naming/Naming.js
new file mode 100644
index 000000000..533c66c6b
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/Naming.js
@@ -0,0 +1,281 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Alert from 'Components/Alert';
+import FieldSet from 'Components/FieldSet';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputButton from 'Components/Form/FormInputButton';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import { inputTypes, kinds, sizes } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import NamingModal from './NamingModal';
+import styles from './Naming.css';
+
+class Naming extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isNamingModalOpen: false,
+ namingModalOptions: null
+ };
+ }
+
+ //
+ // Listeners
+
+ onStandardNamingModalOpenClick = () => {
+ this.setState({
+ isNamingModalOpen: true,
+ namingModalOptions: {
+ name: 'standardTrackFormat',
+ album: true,
+ track: true,
+ additional: true
+ }
+ });
+ };
+
+ onMultiDiscNamingModalOpenClick = () => {
+ this.setState({
+ isNamingModalOpen: true,
+ namingModalOptions: {
+ name: 'multiDiscTrackFormat',
+ album: true,
+ track: true,
+ additional: true
+ }
+ });
+ };
+
+ onArtistFolderNamingModalOpenClick = () => {
+ this.setState({
+ isNamingModalOpen: true,
+ namingModalOptions: {
+ name: 'artistFolderFormat'
+ }
+ });
+ };
+
+ onNamingModalClose = () => {
+ this.setState({ isNamingModalOpen: false });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ examples,
+ examplesPopulated,
+ onInputChange
+ } = this.props;
+
+ const {
+ isNamingModalOpen,
+ namingModalOptions
+ } = this.state;
+
+ const renameTracks = hasSettings && settings.renameTracks.value;
+ const replaceIllegalCharacters = hasSettings && settings.replaceIllegalCharacters.value;
+
+ const colonReplacementOptions = [
+ { key: 0, value: translate('Delete') },
+ { key: 1, value: translate('ReplaceWithDash') },
+ { key: 2, value: translate('ReplaceWithSpaceDash') },
+ { key: 3, value: translate('ReplaceWithSpaceDashSpace') },
+ { key: 4, value: translate('SmartReplace'), hint: translate('DashOrSpaceDashDependingOnName') }
+ ];
+
+ const standardTrackFormatHelpTexts = [];
+ const standardTrackFormatErrors = [];
+ const multiDiscTrackFormatHelpTexts = [];
+ const multiDiscTrackFormatErrors = [];
+ const artistFolderFormatHelpTexts = [];
+ const artistFolderFormatErrors = [];
+
+ if (examplesPopulated) {
+ if (examples.singleTrackExample) {
+ standardTrackFormatHelpTexts.push(`Single Track: ${examples.singleTrackExample}`);
+ } else {
+ standardTrackFormatErrors.push({ message: 'Single Track: Invalid Format' });
+ }
+
+ if (examples.multiDiscTrackExample) {
+ multiDiscTrackFormatHelpTexts.push(`Multi Disc Track: ${examples.multiDiscTrackExample}`);
+ } else {
+ multiDiscTrackFormatErrors.push({ message: 'Single Track: Invalid Format' });
+ }
+
+ if (examples.artistFolderExample) {
+ artistFolderFormatHelpTexts.push(`Example: ${examples.artistFolderExample}`);
+ } else {
+ artistFolderFormatErrors.push({ message: 'Invalid Format' });
+ }
+ }
+
+ return (
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+
+ {translate('UnableToLoadNamingSettings')}
+
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+ }
+
+ );
+ }
+
+}
+
+Naming.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ examples: PropTypes.object.isRequired,
+ examplesPopulated: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default Naming;
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js b/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js
new file mode 100644
index 000000000..8f96fabfb
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js
@@ -0,0 +1,96 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import { fetchNamingExamples, fetchNamingSettings, setNamingSettingsValue } from 'Store/Actions/settingsActions';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import Naming from './Naming';
+
+const SECTION = 'naming';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ (state) => state.settings.namingExamples,
+ createSettingsSectionSelector(SECTION),
+ (advancedSettings, namingExamples, sectionSettings) => {
+ return {
+ advancedSettings,
+ examples: namingExamples.item,
+ examplesPopulated: namingExamples.isPopulated,
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchNamingSettings,
+ setNamingSettingsValue,
+ fetchNamingExamples,
+ clearPendingChanges
+};
+
+class NamingConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._namingExampleTimeout = null;
+ }
+
+ componentDidMount() {
+ this.props.fetchNamingSettings();
+ this.props.fetchNamingExamples();
+ }
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: 'settings.naming' });
+ }
+
+ //
+ // Control
+
+ _fetchNamingExamples = () => {
+ this.props.fetchNamingExamples();
+ };
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setNamingSettingsValue({ name, value });
+
+ if (this._namingExampleTimeout) {
+ clearTimeout(this._namingExampleTimeout);
+ }
+
+ this._namingExampleTimeout = setTimeout(this._fetchNamingExamples, 1000);
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+NamingConnector.propTypes = {
+ fetchNamingSettings: PropTypes.func.isRequired,
+ setNamingSettingsValue: PropTypes.func.isRequired,
+ fetchNamingExamples: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(NamingConnector);
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.css b/frontend/src/Settings/MediaManagement/Naming/NamingModal.css
new file mode 100644
index 000000000..f65bed4df
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.css
@@ -0,0 +1,35 @@
+.groups {
+ display: flex;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ margin-bottom: 20px;
+}
+
+.namingSelectContainer {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.namingSelect {
+ composes: select from '~Components/Form/SelectInput.css';
+
+ margin-left: 10px;
+ width: 200px;
+}
+
+.footNote {
+ display: flex;
+ color: var(--helpTextColor);
+
+ .icon {
+ margin-top: 3px;
+ margin-right: 5px;
+ padding: 2px;
+ }
+
+ code {
+ padding: 0 1px;
+ border: 1px solid var(--borderColor);
+ background-color: #f7f7f7;
+ }
+}
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.css.d.ts b/frontend/src/Settings/MediaManagement/Naming/NamingModal.css.d.ts
new file mode 100644
index 000000000..5dd30b0d3
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.css.d.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'footNote': string;
+ 'groups': string;
+ 'icon': string;
+ 'namingSelect': string;
+ 'namingSelectContainer': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js
new file mode 100644
index 000000000..dec15893f
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js
@@ -0,0 +1,608 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import FieldSet from 'Components/FieldSet';
+import SelectInput from 'Components/Form/SelectInput';
+import TextInput from 'Components/Form/TextInput';
+import Button from 'Components/Link/Button';
+import Modal from 'Components/Modal/Modal';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import { sizes } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import NamingOption from './NamingOption';
+import styles from './NamingModal.css';
+
+const separatorOptions = [
+ {
+ key: ' ',
+ get value() {
+ return `${translate('Space')} ( )`;
+ }
+ },
+ {
+ key: '.',
+ get value() {
+ return `${translate('Period')} (.)`;
+ }
+ },
+ {
+ key: '_',
+ get value() {
+ return `${translate('Underscore')} (_)`;
+ }
+ },
+ {
+ key: '-',
+ get value() {
+ return `${translate('Dash')} (-)`;
+ }
+ }
+];
+
+const caseOptions = [
+ {
+ key: 'title',
+ get value() {
+ return translate('DefaultCase');
+ }
+ },
+ {
+ key: 'lower',
+ get value() {
+ return translate('Lowercase');
+ }
+ },
+ {
+ key: 'upper',
+ get value() {
+ return translate('Uppercase');
+ }
+ }
+];
+
+const fileNameTokens = [
+ {
+ token: '{Artist Name} - {Album Title} - {track:00} - {Track Title} {Quality Full}',
+ example: 'Artist Name - Album Title - 01 - Track Title MP3-320 Proper'
+ },
+ {
+ token: '{Artist.Name}.{Album.Title}.{track:00}.{TrackClean.Title}.{Quality.Full}',
+ example: 'Artist.Name.Album.Title.01.Track.Title.MP3-320'
+ }
+];
+
+const artistTokens = [
+ { token: '{Artist Name}', example: 'Artist Name' },
+ { token: '{Artist CleanName}', example: 'Artist Name' },
+ { token: '{Artist NameThe}', example: 'Artist Name, The' },
+ { token: '{Artist CleanNameThe}', example: 'Artist Name, The' },
+ { token: '{Artist NameFirstCharacter}', example: 'A' },
+ { token: '{Artist Disambiguation}', example: 'Disambiguation' },
+ { token: '{Artist Genre}', example: 'Pop' },
+ { token: '{Artist MbId}', example: 'db92a151-1ac2-438b-bc43-b82e149ddd50' }
+];
+
+const albumTokens = [
+ { token: '{Album Title}', example: 'Album Title' },
+ { token: '{Album CleanTitle}', example: 'Album Title' },
+ { token: '{Album TitleThe}', example: 'Album Title, The' },
+ { token: '{Album CleanTitleThe}', example: 'Album Title, The' },
+ { token: '{Album Type}', example: 'Album Type' },
+ { token: '{Album Disambiguation}', example: 'Disambiguation' },
+ { token: '{Album Genre}', example: 'Rock' },
+ { token: '{Album MbId}', example: '082c6aff-a7cc-36e0-a960-35a578ecd937' }
+];
+
+const mediumTokens = [
+ { token: '{medium:0}', example: '1' },
+ { token: '{medium:00}', example: '01' }
+];
+
+const mediumFormatTokens = [
+ { token: '{Medium Name}', example: 'First Medium' },
+ { token: '{Medium Format}', example: 'CD' }
+];
+
+const trackTokens = [
+ { token: '{track:0}', example: '1' },
+ { token: '{track:00}', example: '01' }
+];
+
+const releaseDateTokens = [
+ { token: '{Release Year}', example: '2016' }
+];
+
+const trackTitleTokens = [
+ { token: '{Track Title}', example: 'Track Title' },
+ { token: '{Track CleanTitle}', example: 'Track Title' }
+];
+
+const trackArtistTokens = [
+ { token: '{Track ArtistName}', example: 'Artist Name' },
+ { token: '{Track ArtistCleanName}', example: 'Artist Name' },
+ { token: '{Track ArtistNameThe}', example: 'Artist Name, The' },
+ { token: '{Track ArtistCleanNameThe}', example: 'Artist Name, The' },
+ { token: '{Track ArtistMbId}', example: 'db92a151-1ac2-438b-bc43-b82e149ddd50' }
+];
+
+const qualityTokens = [
+ { token: '{Quality Full}', example: 'FLAC Proper' },
+ { token: '{Quality Title}', example: 'FLAC' }
+];
+
+const mediaInfoTokens = [
+ { token: '{MediaInfo AudioCodec}', example: 'FLAC' },
+ { token: '{MediaInfo AudioChannels}', example: '2.0' },
+ { token: '{MediaInfo AudioBitRate}', example: '320kbps' },
+ { token: '{MediaInfo AudioBitsPerSample}', example: '24bit' },
+ { token: '{MediaInfo AudioSampleRate}', example: '44.1kHz' }
+];
+
+const otherTokens = [
+ { token: '{Release Group}', example: 'Rls Grp' },
+ { token: '{Custom Formats}', example: 'iNTERNAL' }
+];
+
+const originalTokens = [
+ { token: '{Original Title}', example: 'Artist.Name.Album.Name.2018.FLAC-EVOLVE' },
+ { token: '{Original Filename}', example: '01 - track name' }
+];
+
+class NamingModal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._selectionStart = null;
+ this._selectionEnd = null;
+
+ this.state = {
+ separator: ' ',
+ case: 'title'
+ };
+ }
+
+ //
+ // Listeners
+
+ onTokenSeparatorChange = (event) => {
+ this.setState({ separator: event.value });
+ };
+
+ onTokenCaseChange = (event) => {
+ this.setState({ case: event.value });
+ };
+
+ onInputSelectionChange = (selectionStart, selectionEnd) => {
+ this._selectionStart = selectionStart;
+ this._selectionEnd = selectionEnd;
+ };
+
+ onOptionPress = ({ isFullFilename, tokenValue }) => {
+ const {
+ name,
+ value,
+ onInputChange
+ } = this.props;
+
+ const selectionStart = this._selectionStart;
+ const selectionEnd = this._selectionEnd;
+
+ if (isFullFilename) {
+ onInputChange({ name, value: tokenValue });
+ } else if (selectionStart == null) {
+ onInputChange({
+ name,
+ value: `${value}${tokenValue}`
+ });
+ } else {
+ const start = value.substring(0, selectionStart);
+ const end = value.substring(selectionEnd);
+ const newValue = `${start}${tokenValue}${end}`;
+
+ onInputChange({ name, value: newValue });
+ this._selectionStart = newValue.length - 1;
+ this._selectionEnd = newValue.length - 1;
+ }
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ value,
+ isOpen,
+ advancedSettings,
+ album,
+ track,
+ additional,
+ onInputChange,
+ onModalClose
+ } = this.props;
+
+ const {
+ separator: tokenSeparator,
+ case: tokenCase
+ } = this.state;
+
+ return (
+
+
+
+ {translate('FileNameTokens')}
+
+
+
+
+
+
+
+
+
+ {
+ !advancedSettings &&
+
+
+ {
+ fileNameTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+ }
+
+
+
+ {
+ artistTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+ {
+ album &&
+
+
+
+ {
+ albumTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ releaseDateTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+ }
+
+ {
+ track &&
+
+
+
+ {
+ mediumTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ mediumFormatTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ trackTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+ }
+
+ {
+ additional &&
+
+
+
+ {
+ trackTitleTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ trackArtistTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ qualityTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ mediaInfoTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ otherTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ originalTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+ }
+
+
+
+
+
+ {translate('Close')}
+
+
+
+
+ );
+ }
+}
+
+NamingModal.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ isOpen: PropTypes.bool.isRequired,
+ advancedSettings: PropTypes.bool.isRequired,
+ album: PropTypes.bool.isRequired,
+ track: PropTypes.bool.isRequired,
+ additional: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+NamingModal.defaultProps = {
+ album: false,
+ track: false,
+ additional: false
+};
+
+export default NamingModal;
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.css b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css
new file mode 100644
index 000000000..204c93d0e
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css
@@ -0,0 +1,74 @@
+.option {
+ display: flex;
+ align-items: stretch;
+ flex-wrap: wrap;
+ margin: 3px;
+ border: 1px solid var(--borderColor);
+
+ &:hover {
+ .token {
+ background-color: #ddd;
+ }
+
+ .example {
+ background-color: #ccc;
+ }
+ }
+}
+
+.small {
+ width: 490px;
+}
+
+.large {
+ width: 100%;
+}
+
+.token {
+ flex: 0 0 50%;
+ padding: 6px;
+ background-color: var(--popoverTitleBackgroundColor);
+ font-family: $monoSpaceFontFamily;
+}
+
+.example {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex: 0 0 50%;
+ padding: 6px;
+ background-color: var(--popoverBodyBackgroundColor);
+
+ .footNote {
+ padding: 2px;
+ color: #aaa;
+ }
+}
+
+.lower {
+ text-transform: lowercase;
+}
+
+.upper {
+ text-transform: uppercase;
+}
+
+.isFullFilename {
+ .token,
+ .example {
+ flex: 1 0 auto;
+ }
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .option.small {
+ width: 100%;
+ }
+}
+
+@media only screen and (max-width: $breakpointExtraSmall) {
+ .token,
+ .example {
+ flex: 1 0 auto;
+ }
+}
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.css.d.ts b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css.d.ts
new file mode 100644
index 000000000..a060f6218
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css.d.ts
@@ -0,0 +1,15 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'example': string;
+ 'footNote': string;
+ 'isFullFilename': string;
+ 'large': string;
+ 'lower': string;
+ 'option': string;
+ 'small': string;
+ 'token': string;
+ 'upper': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.js b/frontend/src/Settings/MediaManagement/Naming/NamingOption.js
new file mode 100644
index 000000000..29bf9d3bb
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.js
@@ -0,0 +1,84 @@
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import { sizes } from 'Helpers/Props';
+import styles from './NamingOption.css';
+
+class NamingOption extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ token,
+ tokenSeparator,
+ tokenCase,
+ isFullFilename,
+ onPress
+ } = this.props;
+
+ let tokenValue = token;
+
+ tokenValue = tokenValue.replace(/ /g, tokenSeparator);
+
+ if (tokenCase === 'lower') {
+ tokenValue = token.toLowerCase();
+ } else if (tokenCase === 'upper') {
+ tokenValue = token.toUpperCase();
+ }
+
+ onPress({ isFullFilename, tokenValue });
+ };
+
+ //
+ // Render
+ render() {
+ const {
+ token,
+ tokenSeparator,
+ example,
+ tokenCase,
+ isFullFilename,
+ size
+ } = this.props;
+
+ return (
+
+
+ {token.replace(/ /g, tokenSeparator)}
+
+
+
+ {example.replace(/ /g, tokenSeparator)}
+
+
+ );
+ }
+}
+
+NamingOption.propTypes = {
+ token: PropTypes.string.isRequired,
+ example: PropTypes.string.isRequired,
+ tokenSeparator: PropTypes.string.isRequired,
+ tokenCase: PropTypes.string.isRequired,
+ isFullFilename: PropTypes.bool.isRequired,
+ size: PropTypes.oneOf([sizes.SMALL, sizes.LARGE]),
+ onPress: PropTypes.func.isRequired
+};
+
+NamingOption.defaultProps = {
+ size: sizes.SMALL,
+ isFullFilename: false
+};
+
+export default NamingOption;
diff --git a/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModal.js b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModal.js
new file mode 100644
index 000000000..6adc8046c
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import EditRootFolderModalContentConnector from './EditRootFolderModalContentConnector';
+
+function EditRootFolderModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditRootFolderModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditRootFolderModal;
diff --git a/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalConnector.js b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalConnector.js
new file mode 100644
index 000000000..b3aaea069
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalConnector.js
@@ -0,0 +1,58 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import { cancelSaveRootFolder } from 'Store/Actions/settingsActions';
+import EditRootFolderModal from './EditRootFolderModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ const section = 'settings.rootFolders';
+
+ return {
+ dispatchClearPendingChanges() {
+ dispatch(clearPendingChanges({ section }));
+ },
+
+ dispatchCancelSaveRootFolder() {
+ dispatch(cancelSaveRootFolder({ section }));
+ }
+ };
+}
+
+class EditRootFolderModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.dispatchClearPendingChanges();
+ this.props.dispatchCancelSaveRootFolder();
+ this.props.onModalClose();
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchClearPendingChanges,
+ dispatchCancelSaveRootFolder,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+EditRootFolderModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired,
+ dispatchCancelSaveRootFolder: PropTypes.func.isRequired
+};
+
+export default connect(null, createMapDispatchToProps)(EditRootFolderModalConnector);
diff --git a/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContent.css b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContent.css
new file mode 100644
index 000000000..23e22b6dc
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContent.css
@@ -0,0 +1,15 @@
+.deleteButton {
+ composes: button from '~Components/Link/Button.css';
+
+ margin-right: auto;
+}
+
+.hideMetadataProfile {
+ composes: group from '~Components/Form/FormGroup.css';
+
+ display: none;
+}
+
+.labelIcon {
+ margin-left: 8px;
+}
diff --git a/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContent.css.d.ts b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContent.css.d.ts
new file mode 100644
index 000000000..995399ce7
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContent.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'deleteButton': string;
+ 'hideMetadataProfile': string;
+ 'labelIcon': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContent.js b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContent.js
new file mode 100644
index 000000000..3aab11085
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContent.js
@@ -0,0 +1,255 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import ArtistMetadataProfilePopoverContent from 'AddArtist/ArtistMetadataProfilePopoverContent';
+import ArtistMonitoringOptionsPopoverContent from 'AddArtist/ArtistMonitoringOptionsPopoverContent';
+import ArtistMonitorNewItemsOptionsPopoverContent from 'AddArtist/ArtistMonitorNewItemsOptionsPopoverContent';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import Icon from 'Components/Icon';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import Popover from 'Components/Tooltip/Popover';
+import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import styles from './EditRootFolderModalContent.css';
+
+function EditRootFolderModalContent(props) {
+
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ item,
+ onInputChange,
+ onModalClose,
+ onSavePress,
+ onDeleteRootFolderPress,
+ showMetadataProfile,
+ ...otherProps
+ } = props;
+
+ const {
+ id,
+ name,
+ path,
+ defaultQualityProfileId,
+ defaultMetadataProfileId,
+ defaultMonitorOption,
+ defaultNewItemMonitorOption,
+ defaultTags
+ } = item;
+
+ return (
+
+
+ {id ? translate('EditRootFolder') : translate('AddRootFolder')}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+
+ {translate('UnableToAddANewRootFolderPleaseTryAgain')}
+
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id &&
+
+ {translate('Delete')}
+
+ }
+
+
+ {translate('Cancel')}
+
+
+
+ {translate('Save')}
+
+
+
+ );
+}
+
+EditRootFolderModalContent.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ showMetadataProfile: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onDeleteRootFolderPress: PropTypes.func
+};
+
+export default EditRootFolderModalContent;
diff --git a/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContentConnector.js b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContentConnector.js
new file mode 100644
index 000000000..0562e269b
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContentConnector.js
@@ -0,0 +1,84 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { saveRootFolder, setRootFolderValue } from 'Store/Actions/settingsActions';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import EditRootFolderModalContent from './EditRootFolderModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings.advancedSettings,
+ (state) => state.settings.metadataProfiles,
+ (state) => state.settings.rootFolders,
+ createProviderSettingsSelector('rootFolders'),
+ (id, advancedSettings, metadataProfiles, rootFolders, rootFolderSettings) => {
+ return {
+ advancedSettings,
+ showMetadataProfile: metadataProfiles.items.length > 1,
+ ...rootFolderSettings,
+ isFetching: rootFolders.isFetching
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setRootFolderValue,
+ saveRootFolder
+};
+
+class EditRootFolderModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setRootFolderValue({ name, value });
+ };
+
+ onSavePress = () => {
+ this.props.saveRootFolder({ id: this.props.id });
+
+ if (this.props.onRootFolderAdded) {
+ this.props.onRootFolderAdded(this.props.item.path);
+ }
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditRootFolderModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setRootFolderValue: PropTypes.func.isRequired,
+ saveRootFolder: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onRootFolderAdded: PropTypes.func
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditRootFolderModalContentConnector);
diff --git a/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.css b/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.css
new file mode 100644
index 000000000..0506cc9c6
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.css
@@ -0,0 +1,19 @@
+.rootFolder {
+ composes: card from '~Components/Card.css';
+
+ width: 290px;
+}
+
+.name {
+ @add-mixin truncate;
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.enabled {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+}
diff --git a/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.css.d.ts b/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.css.d.ts
new file mode 100644
index 000000000..7f6c11c57
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'enabled': string;
+ 'name': string;
+ 'rootFolder': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.js b/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.js
new file mode 100644
index 000000000..dc91e4622
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.js
@@ -0,0 +1,117 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import { kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import EditRootFolderModalConnector from './EditRootFolderModalConnector';
+import styles from './RootFolder.css';
+
+class RootFolder extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditRootFolderModalOpen: false,
+ isDeleteRootFolderModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditRootFolderPress = () => {
+ this.setState({ isEditRootFolderModalOpen: true });
+ };
+
+ onEditRootFolderModalClose = () => {
+ this.setState({ isEditRootFolderModalOpen: false });
+ };
+
+ onDeleteRootFolderPress = () => {
+ this.setState({
+ isEditRootFolderModalOpen: false,
+ isDeleteRootFolderModalOpen: true
+ });
+ };
+
+ onDeleteRootFolderModalClose= () => {
+ this.setState({ isDeleteRootFolderModalOpen: false });
+ };
+
+ onConfirmDeleteRootFolder = () => {
+ this.props.onConfirmDeleteRootFolder(this.props.id);
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ path,
+ qualityProfile,
+ metadataProfile
+ } = this.props;
+
+ return (
+
+
+ {name}
+
+
+
+
+ {path}
+
+
+
+ {qualityProfile?.name || translate('None')}
+
+
+
+ {metadataProfile?.name || translate('None')}
+
+
+
+
+
+
+
+ );
+ }
+}
+
+RootFolder.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ path: PropTypes.string.isRequired,
+ qualityProfile: PropTypes.object.isRequired,
+ metadataProfile: PropTypes.object.isRequired,
+ onConfirmDeleteRootFolder: PropTypes.func.isRequired
+};
+
+export default RootFolder;
diff --git a/frontend/src/Settings/MediaManagement/RootFolder/RootFolders.css b/frontend/src/Settings/MediaManagement/RootFolder/RootFolders.css
new file mode 100644
index 000000000..3b3bcc17a
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/RootFolder/RootFolders.css
@@ -0,0 +1,20 @@
+.rootFolders {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addRootFolder {
+ composes: rootFolder from '~./RootFolder.css';
+
+ background-color: var(--cardAlternateBackgroundColor);
+ color: var(--gray);
+ text-align: center;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid var(--borderColor);
+ border-radius: 4px;
+ background-color: var(--cardCenterBackgroundColor);
+}
diff --git a/frontend/src/Settings/MediaManagement/RootFolder/RootFolders.css.d.ts b/frontend/src/Settings/MediaManagement/RootFolder/RootFolders.css.d.ts
new file mode 100644
index 000000000..124ece64f
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/RootFolder/RootFolders.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'addRootFolder': string;
+ 'center': string;
+ 'rootFolders': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/MediaManagement/RootFolder/RootFolders.js b/frontend/src/Settings/MediaManagement/RootFolder/RootFolders.js
new file mode 100644
index 000000000..a945a805e
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/RootFolder/RootFolders.js
@@ -0,0 +1,105 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Card from 'Components/Card';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import { icons } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import EditRootFolderModalConnector from './EditRootFolderModalConnector';
+import RootFolder from './RootFolder';
+import styles from './RootFolders.css';
+
+class RootFolders extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddRootFolderModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddRootFolderPress = () => {
+ this.setState({ isAddRootFolderModalOpen: true });
+ };
+
+ onAddRootFolderModalClose = () => {
+ this.setState({ isAddRootFolderModalOpen: false });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ qualityProfiles,
+ metadataProfiles,
+ onConfirmDeleteRootFolder,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ {
+ items.map((item) => {
+ const qualityProfile = qualityProfiles.find((profile) => profile.id === item.defaultQualityProfileId);
+ const metadataProfile = metadataProfiles.find((profile) => profile.id === item.defaultMetadataProfileId);
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+RootFolders.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ qualityProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ metadataProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteRootFolder: PropTypes.func.isRequired
+};
+
+export default RootFolders;
diff --git a/frontend/src/Settings/MediaManagement/RootFolder/RootFoldersConnector.js b/frontend/src/Settings/MediaManagement/RootFolder/RootFoldersConnector.js
new file mode 100644
index 000000000..e1e8807bb
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/RootFolder/RootFoldersConnector.js
@@ -0,0 +1,63 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { deleteRootFolder, fetchRootFolders } from 'Store/Actions/settingsActions';
+import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector';
+import RootFolders from './RootFolders';
+
+function createMapStateToProps() {
+ return createSelector(
+ createRootFoldersSelector(),
+ (state) => state.settings.qualityProfiles,
+ (state) => state.settings.metadataProfiles,
+ (rootFolders, quality, metadata) => {
+ return {
+ qualityProfiles: quality.items,
+ metadataProfiles: metadata.items,
+ ...rootFolders
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchRootFolders: fetchRootFolders,
+ dispatchDeleteRootFolder: deleteRootFolder
+};
+
+class RootFoldersConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchRootFolders();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteRootFolder = (id) => {
+ this.props.dispatchDeleteRootFolder({ id });
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+RootFoldersConnector.propTypes = {
+ dispatchFetchRootFolders: PropTypes.func.isRequired,
+ dispatchDeleteRootFolder: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(RootFoldersConnector);
diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js
new file mode 100644
index 000000000..4b33df528
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import { sizes } from 'Helpers/Props';
+import EditMetadataModalContentConnector from './EditMetadataModalContentConnector';
+
+function EditMetadataModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditMetadataModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditMetadataModal;
diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js
new file mode 100644
index 000000000..6e415b80c
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js
@@ -0,0 +1,45 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditMetadataModal from './EditMetadataModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ const section = 'settings.metadata';
+
+ return {
+ dispatchClearPendingChanges() {
+ dispatch(clearPendingChanges({ section }));
+ }
+ };
+}
+
+class EditMetadataModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.dispatchClearPendingChanges();
+ this.props.onModalClose();
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditMetadataModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(null, createMapDispatchToProps)(EditMetadataModalConnector);
diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js
new file mode 100644
index 000000000..812478d23
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js
@@ -0,0 +1,104 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import { inputTypes } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+
+function EditMetadataModalContent(props) {
+ const {
+ isSaving,
+ saveError,
+ item,
+ onInputChange,
+ onFieldChange,
+ onModalClose,
+ onSavePress,
+ ...otherProps
+ } = props;
+
+ const {
+ name,
+ enable,
+ fields
+ } = item;
+
+ return (
+
+
+ {translate('EditMetadata')} - {name.value}
+
+
+
+
+
+
+
+
+ {translate('Cancel')}
+
+
+
+ {translate('Save')}
+
+
+
+ );
+}
+
+EditMetadataModalContent.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onFieldChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onDeleteMetadataPress: PropTypes.func
+};
+
+export default EditMetadataModalContent;
diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js
new file mode 100644
index 000000000..f36ac6807
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js
@@ -0,0 +1,93 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { saveMetadata, setMetadataFieldValue, setMetadataValue } from 'Store/Actions/settingsActions';
+import selectSettings from 'Store/Selectors/selectSettings';
+import EditMetadataModalContent from './EditMetadataModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings.metadata,
+ (id, metadata) => {
+ const {
+ isSaving,
+ saveError,
+ pendingChanges,
+ items
+ } = metadata;
+
+ const settings = selectSettings(_.find(items, { id }), pendingChanges, saveError);
+
+ return {
+ id,
+ isSaving,
+ saveError,
+ item: settings.settings,
+ ...settings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setMetadataValue,
+ setMetadataFieldValue,
+ saveMetadata
+};
+
+class EditMetadataModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setMetadataValue({ name, value });
+ };
+
+ onFieldChange = ({ name, value }) => {
+ this.props.setMetadataFieldValue({ name, value });
+ };
+
+ onSavePress = () => {
+ this.props.saveMetadata({ id: this.props.id });
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditMetadataModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setMetadataValue: PropTypes.func.isRequired,
+ setMetadataFieldValue: PropTypes.func.isRequired,
+ saveMetadata: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditMetadataModalContentConnector);
diff --git a/frontend/src/Settings/Metadata/Metadata/Metadata.css b/frontend/src/Settings/Metadata/Metadata/Metadata.css
new file mode 100644
index 000000000..f87b92a81
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/Metadata.css
@@ -0,0 +1,15 @@
+.metadata {
+ composes: card from '~Components/Card.css';
+
+ width: 290px;
+}
+
+.name {
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.section {
+ margin-top: 10px;
+}
diff --git a/frontend/src/Settings/Metadata/Metadata/Metadata.css.d.ts b/frontend/src/Settings/Metadata/Metadata/Metadata.css.d.ts
new file mode 100644
index 000000000..d3d1af505
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/Metadata.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'metadata': string;
+ 'name': string;
+ 'section': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Metadata/Metadata/Metadata.js b/frontend/src/Settings/Metadata/Metadata/Metadata.js
new file mode 100644
index 000000000..7135c87bd
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/Metadata.js
@@ -0,0 +1,149 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import { kinds } from 'Helpers/Props';
+import EditMetadataModalConnector from './EditMetadataModalConnector';
+import styles from './Metadata.css';
+
+class Metadata extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditMetadataModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditMetadataPress = () => {
+ this.setState({ isEditMetadataModalOpen: true });
+ };
+
+ onEditMetadataModalClose = () => {
+ this.setState({ isEditMetadataModalOpen: false });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ enable,
+ fields
+ } = this.props;
+
+ const metadataFields = [];
+ const imageFields = [];
+
+ fields.forEach((field) => {
+ if (field.section === 'metadata') {
+ metadataFields.push(field);
+ } else {
+ imageFields.push(field);
+ }
+ });
+
+ return (
+
+
+ {name}
+
+
+
+ {
+ enable ?
+
+ Enabled
+ :
+
+ Disabled
+
+ }
+
+
+ {
+ enable && !!metadataFields.length &&
+
+
+ Metadata
+
+
+ {
+ metadataFields.map((field) => {
+ if (!field.value) {
+ return null;
+ }
+
+ return (
+
+ {field.label}
+
+ );
+ })
+ }
+
+ }
+
+ {
+ enable && !!imageFields.length &&
+
+
+ Images
+
+
+ {
+ imageFields.map((field) => {
+ if (!field.value) {
+ return null;
+ }
+
+ return (
+
+ {field.label}
+
+ );
+ })
+ }
+
+ }
+
+
+
+ );
+ }
+}
+
+Metadata.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ enable: PropTypes.bool.isRequired,
+ fields: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default Metadata;
diff --git a/frontend/src/Settings/Metadata/Metadata/Metadatas.css b/frontend/src/Settings/Metadata/Metadata/Metadatas.css
new file mode 100644
index 000000000..fb1bd6080
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/Metadatas.css
@@ -0,0 +1,4 @@
+.metadatas {
+ display: flex;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Settings/Metadata/Metadata/Metadatas.css.d.ts b/frontend/src/Settings/Metadata/Metadata/Metadatas.css.d.ts
new file mode 100644
index 000000000..4c9d41dba
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/Metadatas.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'metadatas': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Metadata/Metadata/Metadatas.js b/frontend/src/Settings/Metadata/Metadata/Metadatas.js
new file mode 100644
index 000000000..d6842149a
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/Metadatas.js
@@ -0,0 +1,44 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import FieldSet from 'Components/FieldSet';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import translate from 'Utilities/String/translate';
+import Metadata from './Metadata';
+import styles from './Metadatas.css';
+
+function Metadatas(props) {
+ const {
+ items,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ );
+}
+
+Metadatas.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default Metadatas;
diff --git a/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js b/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js
new file mode 100644
index 000000000..8675f4742
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js
@@ -0,0 +1,47 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchMetadata } from 'Store/Actions/settingsActions';
+import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
+import sortByProp from 'Utilities/Array/sortByProp';
+import Metadatas from './Metadatas';
+
+function createMapStateToProps() {
+ return createSelector(
+ createSortedSectionSelector('settings.metadata', sortByProp('name')),
+ (metadata) => metadata
+ );
+}
+
+const mapDispatchToProps = {
+ fetchMetadata
+};
+
+class MetadatasConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchMetadata();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MetadatasConnector.propTypes = {
+ fetchMetadata: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector);
diff --git a/frontend/src/Settings/Metadata/MetadataProvider/MetadataProvider.js b/frontend/src/Settings/Metadata/MetadataProvider/MetadataProvider.js
new file mode 100644
index 000000000..10448892c
--- /dev/null
+++ b/frontend/src/Settings/Metadata/MetadataProvider/MetadataProvider.js
@@ -0,0 +1,112 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Alert from 'Components/Alert';
+import FieldSet from 'Components/FieldSet';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import { inputTypes, kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+
+const writeAudioTagOptions = [
+ { key: 'sync', value: 'All files; keep in sync with MusicBrainz' },
+ { key: 'allFiles', value: 'All files; initial import only' },
+ { key: 'newFiles', value: 'For new downloads only' },
+ { key: 'no', value: 'Never' }
+];
+
+function MetadataProvider(props) {
+ const {
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ onInputChange
+ } = props;
+
+ return (
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+
+ {translate('UnableToLoadMetadataProviderSettings')}
+
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+ }
+
+
+ );
+}
+
+MetadataProvider.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default MetadataProvider;
diff --git a/frontend/src/Settings/Metadata/MetadataProvider/MetadataProviderConnector.js b/frontend/src/Settings/Metadata/MetadataProvider/MetadataProviderConnector.js
new file mode 100644
index 000000000..d88807580
--- /dev/null
+++ b/frontend/src/Settings/Metadata/MetadataProvider/MetadataProviderConnector.js
@@ -0,0 +1,101 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import { fetchMetadataProvider, saveMetadataProvider, setMetadataProviderValue } from 'Store/Actions/settingsActions';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import MetadataProvider from './MetadataProvider';
+
+const SECTION = 'metadataProvider';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createSettingsSectionSelector(SECTION),
+ (advancedSettings, sectionSettings) => {
+ return {
+ advancedSettings,
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchMetadataProvider: fetchMetadataProvider,
+ dispatchSetMetadataProviderValue: setMetadataProviderValue,
+ dispatchSaveMetadataProvider: saveMetadataProvider,
+ dispatchClearPendingChanges: clearPendingChanges
+};
+
+class MetadataProviderConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ dispatchFetchMetadataProvider,
+ dispatchSaveMetadataProvider,
+ onChildMounted
+ } = this.props;
+
+ dispatchFetchMetadataProvider();
+ onChildMounted(dispatchSaveMetadataProvider);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ hasPendingChanges,
+ isSaving,
+ onChildStateChange
+ } = this.props;
+
+ if (
+ prevProps.isSaving !== isSaving ||
+ prevProps.hasPendingChanges !== hasPendingChanges
+ ) {
+ onChildStateChange({
+ isSaving,
+ hasPendingChanges
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.dispatchClearPendingChanges({ section: 'settings.metadataProvider' });
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.dispatchSetMetadataProviderValue({ name, value });
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MetadataProviderConnector.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ hasPendingChanges: PropTypes.bool.isRequired,
+ dispatchFetchMetadataProvider: PropTypes.func.isRequired,
+ dispatchSetMetadataProviderValue: PropTypes.func.isRequired,
+ dispatchSaveMetadataProvider: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired,
+ onChildMounted: PropTypes.func.isRequired,
+ onChildStateChange: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(MetadataProviderConnector);
diff --git a/frontend/src/Settings/Metadata/MetadataSettings.js b/frontend/src/Settings/Metadata/MetadataSettings.js
new file mode 100644
index 000000000..82fe8ec3a
--- /dev/null
+++ b/frontend/src/Settings/Metadata/MetadataSettings.js
@@ -0,0 +1,70 @@
+import React, { Component } from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import translate from 'Utilities/String/translate';
+import MetadatasConnector from './Metadata/MetadatasConnector';
+import MetadataProviderConnector from './MetadataProvider/MetadataProviderConnector';
+
+class MetadataSettings extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._saveCallback = null;
+
+ this.state = {
+ isSaving: false,
+ hasPendingChanges: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onChildMounted = (saveCallback) => {
+ this._saveCallback = saveCallback;
+ };
+
+ onChildStateChange = (payload) => {
+ this.setState(payload);
+ };
+
+ onSavePress = () => {
+ if (this._saveCallback) {
+ this._saveCallback();
+ }
+ };
+
+ //
+ // Render
+ render() {
+ const {
+ isSaving,
+ hasPendingChanges
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default MetadataSettings;
diff --git a/frontend/src/Settings/Notifications/NotificationSettings.js b/frontend/src/Settings/Notifications/NotificationSettings.js
new file mode 100644
index 000000000..991624463
--- /dev/null
+++ b/frontend/src/Settings/Notifications/NotificationSettings.js
@@ -0,0 +1,22 @@
+import React from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import translate from 'Utilities/String/translate';
+import NotificationsConnector from './Notifications/NotificationsConnector';
+
+function NotificationSettings() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export default NotificationSettings;
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css
new file mode 100644
index 000000000..a9e416098
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css
@@ -0,0 +1,44 @@
+.notification {
+ composes: card from '~Components/Card.css';
+
+ position: relative;
+ width: 300px;
+ height: 100px;
+}
+
+.underlay {
+ @add-mixin cover;
+}
+
+.overlay {
+ @add-mixin linkOverlay;
+
+ padding: 10px;
+}
+
+.name {
+ text-align: center;
+ font-weight: lighter;
+ font-size: 24px;
+}
+
+.actions {
+ margin-top: 20px;
+ text-align: right;
+}
+
+.presetsMenu {
+ composes: menu from '~Components/Menu/Menu.css';
+
+ display: inline-block;
+ margin: 0 5px;
+}
+
+.presetsMenuButton {
+ composes: button from '~Components/Link/Button.css';
+
+ &::after {
+ margin-left: 5px;
+ content: '\25BE';
+ }
+}
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css.d.ts b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css.d.ts
new file mode 100644
index 000000000..952689303
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css.d.ts
@@ -0,0 +1,13 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'actions': string;
+ 'name': string;
+ 'notification': string;
+ 'overlay': string;
+ 'presetsMenu': string;
+ 'presetsMenuButton': string;
+ 'underlay': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.js
new file mode 100644
index 000000000..4cf8a6d57
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.js
@@ -0,0 +1,111 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Button from 'Components/Link/Button';
+import Link from 'Components/Link/Link';
+import Menu from 'Components/Menu/Menu';
+import MenuContent from 'Components/Menu/MenuContent';
+import { sizes } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import AddNotificationPresetMenuItem from './AddNotificationPresetMenuItem';
+import styles from './AddNotificationItem.css';
+
+class AddNotificationItem extends Component {
+
+ //
+ // Listeners
+
+ onNotificationSelect = () => {
+ const {
+ implementation
+ } = this.props;
+
+ this.props.onNotificationSelect({ implementation });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ implementation,
+ implementationName,
+ infoLink,
+ presets,
+ onNotificationSelect
+ } = this.props;
+
+ const hasPresets = !!presets && !!presets.length;
+
+ return (
+
+
+
+
+
+ {implementationName}
+
+
+
+ {
+ hasPresets &&
+
+
+ {translate('Custom')}
+
+
+
+
+ {translate('Presets')}
+
+
+
+ {
+ presets.map((preset) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+ {translate('MoreInfo')}
+
+
+
+
+ );
+ }
+}
+
+AddNotificationItem.propTypes = {
+ implementation: PropTypes.string.isRequired,
+ implementationName: PropTypes.string.isRequired,
+ infoLink: PropTypes.string.isRequired,
+ presets: PropTypes.arrayOf(PropTypes.object),
+ onNotificationSelect: PropTypes.func.isRequired
+};
+
+export default AddNotificationItem;
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModal.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationModal.js
new file mode 100644
index 000000000..45f5e14b6
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AddNotificationModalContentConnector from './AddNotificationModalContentConnector';
+
+function AddNotificationModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+AddNotificationModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddNotificationModal;
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css
new file mode 100644
index 000000000..8744e516c
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css
@@ -0,0 +1,5 @@
+.notifications {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css.d.ts b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css.d.ts
new file mode 100644
index 000000000..172f4fc5b
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'notifications': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js
new file mode 100644
index 000000000..705ae58e4
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import translate from 'Utilities/String/translate';
+import AddNotificationItem from './AddNotificationItem';
+import styles from './AddNotificationModalContent.css';
+
+class AddNotificationModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ schema,
+ onNotificationSelect,
+ onModalClose
+ } = this.props;
+
+ return (
+
+
+ {translate('AddConnection')}
+
+
+
+ {
+ isSchemaFetching &&
+
+ }
+
+ {
+ !isSchemaFetching && !!schemaError &&
+
+ {translate('UnableToAddANewNotificationPleaseTryAgain')}
+
+ }
+
+ {
+ isSchemaPopulated && !schemaError &&
+
+
+ {
+ schema.map((notification) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+AddNotificationModalContent.propTypes = {
+ isSchemaFetching: PropTypes.bool.isRequired,
+ isSchemaPopulated: PropTypes.bool.isRequired,
+ schemaError: PropTypes.object,
+ schema: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onNotificationSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddNotificationModalContent;
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js
new file mode 100644
index 000000000..749038688
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js
@@ -0,0 +1,70 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchNotificationSchema, selectNotificationSchema } from 'Store/Actions/settingsActions';
+import AddNotificationModalContent from './AddNotificationModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.notifications,
+ (notifications) => {
+ const {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ schema
+ } = notifications;
+
+ return {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ schema
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchNotificationSchema,
+ selectNotificationSchema
+};
+
+class AddNotificationModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchNotificationSchema();
+ }
+
+ //
+ // Listeners
+
+ onNotificationSelect = ({ implementation, name }) => {
+ this.props.selectNotificationSchema({ implementation, presetName: name });
+ this.props.onModalClose({ notificationSelected: true });
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AddNotificationModalContentConnector.propTypes = {
+ fetchNotificationSchema: PropTypes.func.isRequired,
+ selectNotificationSchema: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AddNotificationModalContentConnector);
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js
new file mode 100644
index 000000000..dd325906f
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import MenuItem from 'Components/Menu/MenuItem';
+
+class AddNotificationPresetMenuItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ implementation
+ } = this.props;
+
+ this.props.onPress({
+ name,
+ implementation
+ });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ implementation,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {name}
+
+ );
+ }
+}
+
+AddNotificationPresetMenuItem.propTypes = {
+ name: PropTypes.string.isRequired,
+ implementation: PropTypes.string.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default AddNotificationPresetMenuItem;
diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js
new file mode 100644
index 000000000..bc7f4ab18
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import { sizes } from 'Helpers/Props';
+import EditNotificationModalContentConnector from './EditNotificationModalContentConnector';
+
+function EditNotificationModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditNotificationModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditNotificationModal;
diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.js
new file mode 100644
index 000000000..91070a979
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.js
@@ -0,0 +1,65 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import { cancelSaveNotification, cancelTestNotification } from 'Store/Actions/settingsActions';
+import EditNotificationModal from './EditNotificationModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ const section = 'settings.notifications';
+
+ return {
+ dispatchClearPendingChanges() {
+ dispatch(clearPendingChanges({ section }));
+ },
+
+ dispatchCancelTestNotification() {
+ dispatch(cancelTestNotification({ section }));
+ },
+
+ dispatchCancelSaveNotification() {
+ dispatch(cancelSaveNotification({ section }));
+ }
+ };
+}
+
+class EditNotificationModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.dispatchClearPendingChanges();
+ this.props.dispatchCancelTestNotification();
+ this.props.dispatchCancelSaveNotification();
+ this.props.onModalClose();
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchClearPendingChanges,
+ dispatchCancelTestNotification,
+ dispatchCancelSaveNotification,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+EditNotificationModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired,
+ dispatchCancelTestNotification: PropTypes.func.isRequired,
+ dispatchCancelSaveNotification: PropTypes.func.isRequired
+};
+
+export default connect(null, createMapDispatchToProps)(EditNotificationModalConnector);
diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css
new file mode 100644
index 000000000..8e1c16507
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css
@@ -0,0 +1,11 @@
+.deleteButton {
+ composes: button from '~Components/Link/Button.css';
+
+ margin-right: auto;
+}
+
+.message {
+ composes: alert from '~Components/Alert.css';
+
+ margin-bottom: 30px;
+}
diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css.d.ts b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css.d.ts
new file mode 100644
index 000000000..37d918628
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'deleteButton': string;
+ 'message': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js
new file mode 100644
index 000000000..ee51799f2
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js
@@ -0,0 +1,194 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Alert from 'Components/Alert';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import { inputTypes, kinds } from 'Helpers/Props';
+import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton';
+import translate from 'Utilities/String/translate';
+import NotificationEventItems from './NotificationEventItems';
+import styles from './EditNotificationModalContent.css';
+
+function EditNotificationModalContent(props) {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ isSaving,
+ isTesting,
+ saveError,
+ item,
+ onInputChange,
+ onFieldChange,
+ onModalClose,
+ onSavePress,
+ onTestPress,
+ onAdvancedSettingsPress,
+ onDeleteNotificationPress,
+ ...otherProps
+ } = props;
+
+ const {
+ id,
+ implementationName,
+ name,
+ tags,
+ fields,
+ message
+ } = item;
+
+ return (
+
+
+ {id ? translate('EditConnectionImplementation', { implementationName }) : translate('AddConnectionImplementation', { implementationName })}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+
+ {translate('UnableToAddANewNotificationPleaseTryAgain')}
+
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id &&
+
+ {translate('Delete')}
+
+ }
+
+
+
+
+ {translate('Test')}
+
+
+
+ {translate('Cancel')}
+
+
+
+ {translate('Save')}
+
+
+
+ );
+}
+
+EditNotificationModalContent.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ isTesting: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onFieldChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onTestPress: PropTypes.func.isRequired,
+ onAdvancedSettingsPress: PropTypes.func.isRequired,
+ onDeleteNotificationPress: PropTypes.func
+};
+
+export default EditNotificationModalContent;
diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js
new file mode 100644
index 000000000..658d72da8
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js
@@ -0,0 +1,101 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import {
+ saveNotification,
+ setNotificationFieldValue,
+ setNotificationValue,
+ testNotification,
+ toggleAdvancedSettings
+} from 'Store/Actions/settingsActions';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import EditNotificationModalContent from './EditNotificationModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createProviderSettingsSelector('notifications'),
+ (advancedSettings, notification) => {
+ return {
+ advancedSettings,
+ ...notification
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setNotificationValue,
+ setNotificationFieldValue,
+ saveNotification,
+ testNotification,
+ toggleAdvancedSettings
+};
+
+class EditNotificationModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setNotificationValue({ name, value });
+ };
+
+ onFieldChange = ({ name, value }) => {
+ this.props.setNotificationFieldValue({ name, value });
+ };
+
+ onSavePress = () => {
+ this.props.saveNotification({ id: this.props.id });
+ };
+
+ onTestPress = () => {
+ this.props.testNotification({ id: this.props.id });
+ };
+
+ onAdvancedSettingsPress = () => {
+ this.props.toggleAdvancedSettings();
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditNotificationModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setNotificationValue: PropTypes.func.isRequired,
+ setNotificationFieldValue: PropTypes.func.isRequired,
+ saveNotification: PropTypes.func.isRequired,
+ testNotification: PropTypes.func.isRequired,
+ toggleAdvancedSettings: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditNotificationModalContentConnector);
diff --git a/frontend/src/Settings/Notifications/Notifications/Notification.css b/frontend/src/Settings/Notifications/Notifications/Notification.css
new file mode 100644
index 000000000..d7717d8c9
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/Notification.css
@@ -0,0 +1,19 @@
+.notification {
+ composes: card from '~Components/Card.css';
+
+ width: 290px;
+}
+
+.name {
+ @add-mixin truncate;
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.enabled {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+}
diff --git a/frontend/src/Settings/Notifications/Notifications/Notification.css.d.ts b/frontend/src/Settings/Notifications/Notifications/Notification.css.d.ts
new file mode 100644
index 000000000..5c52ad42f
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/Notification.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'enabled': string;
+ 'name': string;
+ 'notification': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Notifications/Notifications/Notification.js b/frontend/src/Settings/Notifications/Notifications/Notification.js
new file mode 100644
index 000000000..21f0e5ca8
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/Notification.js
@@ -0,0 +1,274 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import TagList from 'Components/TagList';
+import { kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import EditNotificationModalConnector from './EditNotificationModalConnector';
+import styles from './Notification.css';
+
+class Notification extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditNotificationModalOpen: false,
+ isDeleteNotificationModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditNotificationPress = () => {
+ this.setState({ isEditNotificationModalOpen: true });
+ };
+
+ onEditNotificationModalClose = () => {
+ this.setState({ isEditNotificationModalOpen: false });
+ };
+
+ onDeleteNotificationPress = () => {
+ this.setState({
+ isEditNotificationModalOpen: false,
+ isDeleteNotificationModalOpen: true
+ });
+ };
+
+ onDeleteNotificationModalClose= () => {
+ this.setState({ isDeleteNotificationModalOpen: false });
+ };
+
+ onConfirmDeleteNotification = () => {
+ this.props.onConfirmDeleteNotification(this.props.id);
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ onGrab,
+ onReleaseImport,
+ onUpgrade,
+ onRename,
+ onArtistAdd,
+ onArtistDelete,
+ onAlbumDelete,
+ onHealthIssue,
+ onHealthRestored,
+ onDownloadFailure,
+ onImportFailure,
+ onTrackRetag,
+ onApplicationUpdate,
+ supportsOnGrab,
+ supportsOnReleaseImport,
+ supportsOnUpgrade,
+ supportsOnRename,
+ supportsOnArtistAdd,
+ supportsOnArtistDelete,
+ supportsOnAlbumDelete,
+ supportsOnHealthIssue,
+ supportsOnHealthRestored,
+ supportsOnDownloadFailure,
+ supportsOnImportFailure,
+ supportsOnTrackRetag,
+ supportsOnApplicationUpdate,
+ tags,
+ tagList
+ } = this.props;
+
+ return (
+
+
+ {name}
+
+
+ {
+ supportsOnGrab && onGrab ?
+
+ {translate('OnGrab')}
+ :
+ null
+ }
+
+ {
+ supportsOnReleaseImport && onReleaseImport ?
+
+ {translate('OnReleaseImport')}
+ :
+ null
+ }
+
+ {
+ supportsOnUpgrade && onReleaseImport && onUpgrade ?
+
+ {translate('OnUpgrade')}
+ :
+ null
+ }
+
+ {
+ supportsOnRename && onRename ?
+
+ {translate('OnRename')}
+ :
+ null
+ }
+
+ {
+ supportsOnTrackRetag && onTrackRetag ?
+
+ {translate('OnTrackRetag')}
+ :
+ null
+ }
+
+ {
+ supportsOnArtistAdd && onArtistAdd ?
+
+ {translate('OnArtistAdd')}
+ :
+ null
+ }
+
+ {
+ supportsOnArtistDelete && onArtistDelete ?
+
+ {translate('OnArtistDelete')}
+ :
+ null
+ }
+
+ {
+ supportsOnAlbumDelete && onAlbumDelete ?
+
+ {translate('OnAlbumDelete')}
+ :
+ null
+ }
+
+ {
+ supportsOnHealthIssue && onHealthIssue ?
+
+ {translate('OnHealthIssue')}
+ :
+ null
+ }
+
+ {
+ supportsOnHealthRestored && onHealthRestored ?
+
+ {translate('OnHealthRestored')}
+ :
+ null
+ }
+
+ {
+ supportsOnDownloadFailure && onDownloadFailure ?
+
+ {translate('OnDownloadFailure')}
+ :
+ null
+ }
+
+ {
+ supportsOnImportFailure && onImportFailure ?
+
+ {translate('OnImportFailure')}
+ :
+ null
+ }
+
+ {
+ supportsOnApplicationUpdate && onApplicationUpdate ?
+
+ {translate('OnApplicationUpdate')}
+ :
+ null
+ }
+
+ {
+ !onGrab && !onReleaseImport && !onRename && !onTrackRetag && !onArtistAdd && !onArtistDelete && !onAlbumDelete && !onHealthIssue && !onHealthRestored && !onDownloadFailure && !onImportFailure && !onApplicationUpdate ?
+
+ {translate('Disabled')}
+ :
+ null
+ }
+
+
+
+
+
+
+
+ );
+ }
+}
+
+Notification.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ onGrab: PropTypes.bool.isRequired,
+ onReleaseImport: PropTypes.bool.isRequired,
+ onUpgrade: PropTypes.bool.isRequired,
+ onRename: PropTypes.bool.isRequired,
+ onArtistAdd: PropTypes.bool.isRequired,
+ onArtistDelete: PropTypes.bool.isRequired,
+ onAlbumDelete: PropTypes.bool.isRequired,
+ onHealthIssue: PropTypes.bool.isRequired,
+ onHealthRestored: PropTypes.bool.isRequired,
+ onDownloadFailure: PropTypes.bool.isRequired,
+ onImportFailure: PropTypes.bool.isRequired,
+ onTrackRetag: PropTypes.bool.isRequired,
+ onApplicationUpdate: PropTypes.bool.isRequired,
+ supportsOnGrab: PropTypes.bool.isRequired,
+ supportsOnReleaseImport: PropTypes.bool.isRequired,
+ supportsOnUpgrade: PropTypes.bool.isRequired,
+ supportsOnRename: PropTypes.bool.isRequired,
+ supportsOnArtistAdd: PropTypes.bool.isRequired,
+ supportsOnArtistDelete: PropTypes.bool.isRequired,
+ supportsOnAlbumDelete: PropTypes.bool.isRequired,
+ supportsOnHealthIssue: PropTypes.bool.isRequired,
+ supportsOnHealthRestored: PropTypes.bool.isRequired,
+ supportsOnDownloadFailure: PropTypes.bool.isRequired,
+ supportsOnImportFailure: PropTypes.bool.isRequired,
+ supportsOnTrackRetag: PropTypes.bool.isRequired,
+ supportsOnApplicationUpdate: PropTypes.bool.isRequired,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteNotification: PropTypes.func.isRequired
+};
+
+export default Notification;
diff --git a/frontend/src/Settings/Notifications/Notifications/NotificationEventItems.css b/frontend/src/Settings/Notifications/Notifications/NotificationEventItems.css
new file mode 100644
index 000000000..b3f6aa717
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/NotificationEventItems.css
@@ -0,0 +1,4 @@
+.events {
+ margin-top: 10px;
+ user-select: none;
+}
diff --git a/frontend/src/Settings/Notifications/Notifications/NotificationEventItems.css.d.ts b/frontend/src/Settings/Notifications/Notifications/NotificationEventItems.css.d.ts
new file mode 100644
index 000000000..931fd2781
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/NotificationEventItems.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'events': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Notifications/Notifications/NotificationEventItems.js b/frontend/src/Settings/Notifications/Notifications/NotificationEventItems.js
new file mode 100644
index 000000000..bff7b5ef7
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/NotificationEventItems.js
@@ -0,0 +1,228 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormInputHelpText from 'Components/Form/FormInputHelpText';
+import FormLabel from 'Components/Form/FormLabel';
+import { inputTypes } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import styles from './NotificationEventItems.css';
+
+function NotificationEventItems(props) {
+ const {
+ item,
+ onInputChange
+ } = props;
+
+ const {
+ onGrab,
+ onReleaseImport,
+ onUpgrade,
+ onRename,
+ onArtistAdd,
+ onArtistDelete,
+ onAlbumDelete,
+ onHealthIssue,
+ onHealthRestored,
+ onDownloadFailure,
+ onImportFailure,
+ onTrackRetag,
+ onApplicationUpdate,
+ supportsOnGrab,
+ supportsOnReleaseImport,
+ supportsOnUpgrade,
+ supportsOnRename,
+ supportsOnArtistAdd,
+ supportsOnArtistDelete,
+ supportsOnAlbumDelete,
+ supportsOnHealthIssue,
+ supportsOnHealthRestored,
+ includeHealthWarnings,
+ supportsOnDownloadFailure,
+ supportsOnImportFailure,
+ supportsOnTrackRetag,
+ supportsOnApplicationUpdate
+ } = item;
+
+ return (
+
+
+ {translate('NotificationTriggers')}
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ onReleaseImport.value &&
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ (onHealthIssue.value || onHealthRestored.value) &&
+
+
+
+ }
+
+
+
+ );
+}
+
+NotificationEventItems.propTypes = {
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default NotificationEventItems;
diff --git a/frontend/src/Settings/Notifications/Notifications/Notifications.css b/frontend/src/Settings/Notifications/Notifications/Notifications.css
new file mode 100644
index 000000000..986226ad8
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/Notifications.css
@@ -0,0 +1,20 @@
+.notifications {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addNotification {
+ composes: notification from '~./Notification.css';
+
+ background-color: var(--cardAlternateBackgroundColor);
+ color: var(--gray);
+ text-align: center;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid var(--borderColor);
+ border-radius: 4px;
+ background-color: var(--cardCenterBackgroundColor);
+}
diff --git a/frontend/src/Settings/Notifications/Notifications/Notifications.css.d.ts b/frontend/src/Settings/Notifications/Notifications/Notifications.css.d.ts
new file mode 100644
index 000000000..2607f835d
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/Notifications.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'addNotification': string;
+ 'center': string;
+ 'notifications': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Notifications/Notifications/Notifications.js b/frontend/src/Settings/Notifications/Notifications/Notifications.js
new file mode 100644
index 000000000..81b1b90b3
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/Notifications.js
@@ -0,0 +1,118 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Card from 'Components/Card';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import { icons } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import AddNotificationModal from './AddNotificationModal';
+import EditNotificationModalConnector from './EditNotificationModalConnector';
+import Notification from './Notification';
+import styles from './Notifications.css';
+
+class Notifications extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddNotificationModalOpen: false,
+ isEditNotificationModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddNotificationPress = () => {
+ this.setState({ isAddNotificationModalOpen: true });
+ };
+
+ onAddNotificationModalClose = ({ notificationSelected = false } = {}) => {
+ this.setState({
+ isAddNotificationModalOpen: false,
+ isEditNotificationModalOpen: notificationSelected
+ });
+ };
+
+ onEditNotificationModalClose = () => {
+ this.setState({ isEditNotificationModalOpen: false });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ tagList,
+ onConfirmDeleteNotification,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isAddNotificationModalOpen,
+ isEditNotificationModalOpen
+ } = this.state;
+
+ return (
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+Notifications.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteNotification: PropTypes.func.isRequired
+};
+
+export default Notifications;
diff --git a/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js b/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js
new file mode 100644
index 000000000..6351c6f8a
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js
@@ -0,0 +1,63 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { deleteNotification, fetchNotifications } from 'Store/Actions/settingsActions';
+import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import sortByProp from 'Utilities/Array/sortByProp';
+import Notifications from './Notifications';
+
+function createMapStateToProps() {
+ return createSelector(
+ createSortedSectionSelector('settings.notifications', sortByProp('name')),
+ createTagsSelector(),
+ (notifications, tagList) => {
+ return {
+ ...notifications,
+ tagList
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchNotifications,
+ deleteNotification
+};
+
+class NotificationsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchNotifications();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteNotification = (id) => {
+ this.props.deleteNotification({ id });
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+NotificationsConnector.propTypes = {
+ fetchNotifications: PropTypes.func.isRequired,
+ deleteNotification: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(NotificationsConnector);
diff --git a/frontend/src/Settings/PendingChangesModal.js b/frontend/src/Settings/PendingChangesModal.js
new file mode 100644
index 000000000..900022ca1
--- /dev/null
+++ b/frontend/src/Settings/PendingChangesModal.js
@@ -0,0 +1,76 @@
+import PropTypes from 'prop-types';
+import React, { useEffect } from 'react';
+import keyboardShortcuts from 'Components/keyboardShortcuts';
+import Button from 'Components/Link/Button';
+import Modal from 'Components/Modal/Modal';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import { kinds } from 'Helpers/Props';
+
+function PendingChangesModal(props) {
+ const {
+ isOpen,
+ onConfirm,
+ onCancel,
+ bindShortcut,
+ unbindShortcut
+ } = props;
+
+ useEffect(() => {
+ if (isOpen) {
+ bindShortcut('enter', onConfirm);
+
+ return () => unbindShortcut('enter', onConfirm);
+ }
+ }, [bindShortcut, unbindShortcut, isOpen, onConfirm]);
+
+ return (
+
+
+ Unsaved Changes
+
+
+ You have unsaved changes, are you sure you want to leave this page?
+
+
+
+
+ Stay and review changes
+
+
+
+ Discard changes and leave
+
+
+
+
+ );
+}
+
+PendingChangesModal.propTypes = {
+ className: PropTypes.string,
+ isOpen: PropTypes.bool.isRequired,
+ kind: PropTypes.oneOf(kinds.all),
+ onConfirm: PropTypes.func.isRequired,
+ onCancel: PropTypes.func.isRequired,
+ bindShortcut: PropTypes.func.isRequired,
+ unbindShortcut: PropTypes.func.isRequired
+};
+
+PendingChangesModal.defaultProps = {
+ kind: kinds.PRIMARY
+};
+
+export default keyboardShortcuts(PendingChangesModal);
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfile.css b/frontend/src/Settings/Profiles/Delay/DelayProfile.css
new file mode 100644
index 000000000..e2d6cd199
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfile.css
@@ -0,0 +1,40 @@
+.delayProfile {
+ display: flex;
+ align-items: stretch;
+ margin-bottom: 10px;
+ height: 30px;
+ border-bottom: 1px solid var(--borderColor);
+ line-height: 30px;
+}
+
+.column {
+ flex: 0 0 200px;
+}
+
+.actions {
+ display: flex;
+}
+
+.dragHandle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-left: auto;
+ width: $dragHandleWidth;
+ text-align: center;
+ cursor: grab;
+}
+
+.dragIcon {
+ top: 0;
+}
+
+.isDragging {
+ opacity: 0.25;
+}
+
+.editButton {
+ width: $dragHandleWidth;
+ text-align: center;
+}
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfile.css.d.ts b/frontend/src/Settings/Profiles/Delay/DelayProfile.css.d.ts
new file mode 100644
index 000000000..4ec05c2d6
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfile.css.d.ts
@@ -0,0 +1,13 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'actions': string;
+ 'column': string;
+ 'delayProfile': string;
+ 'dragHandle': string;
+ 'dragIcon': string;
+ 'editButton': string;
+ 'isDragging': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfile.js b/frontend/src/Settings/Profiles/Delay/DelayProfile.js
new file mode 100644
index 000000000..ab646dd91
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfile.js
@@ -0,0 +1,173 @@
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import TagList from 'Components/TagList';
+import { icons, kinds } from 'Helpers/Props';
+import titleCase from 'Utilities/String/titleCase';
+import translate from 'Utilities/String/translate';
+import EditDelayProfileModalConnector from './EditDelayProfileModalConnector';
+import styles from './DelayProfile.css';
+
+function getDelay(enabled, delay) {
+ if (!enabled) {
+ return '-';
+ }
+
+ if (!delay) {
+ return 'No Delay';
+ }
+
+ if (delay === 1) {
+ return '1 Minute';
+ }
+
+ // TODO: use better units of time than just minutes
+ return `${delay} Minutes`;
+}
+
+class DelayProfile extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditDelayProfileModalOpen: false,
+ isDeleteDelayProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditDelayProfilePress = () => {
+ this.setState({ isEditDelayProfileModalOpen: true });
+ };
+
+ onEditDelayProfileModalClose = () => {
+ this.setState({ isEditDelayProfileModalOpen: false });
+ };
+
+ onDeleteDelayProfilePress = () => {
+ this.setState({
+ isEditDelayProfileModalOpen: false,
+ isDeleteDelayProfileModalOpen: true
+ });
+ };
+
+ onDeleteDelayProfileModalClose = () => {
+ this.setState({ isDeleteDelayProfileModalOpen: false });
+ };
+
+ onConfirmDeleteDelayProfile = () => {
+ this.props.onConfirmDeleteDelayProfile(this.props.id);
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ enableUsenet,
+ enableTorrent,
+ preferredProtocol,
+ usenetDelay,
+ torrentDelay,
+ tags,
+ tagList,
+ isDragging,
+ connectDragSource
+ } = this.props;
+
+ let preferred = titleCase(translate('PreferProtocol', { preferredProtocol }));
+
+ if (!enableUsenet) {
+ preferred = translate('OnlyTorrent');
+ } else if (!enableTorrent) {
+ preferred = translate('OnlyUsenet');
+ }
+
+ return (
+
+
{preferred}
+
{getDelay(enableUsenet, usenetDelay)}
+
{getDelay(enableTorrent, torrentDelay)}
+
+
+
+
+
+
+
+
+ {
+ id !== 1 &&
+ connectDragSource(
+
+
+
+ )
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+DelayProfile.propTypes = {
+ id: PropTypes.number.isRequired,
+ enableUsenet: PropTypes.bool.isRequired,
+ enableTorrent: PropTypes.bool.isRequired,
+ preferredProtocol: PropTypes.string.isRequired,
+ usenetDelay: PropTypes.number.isRequired,
+ torrentDelay: PropTypes.number.isRequired,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDragging: PropTypes.bool.isRequired,
+ connectDragSource: PropTypes.func,
+ onConfirmDeleteDelayProfile: PropTypes.func.isRequired
+};
+
+DelayProfile.defaultProps = {
+ // The drag preview will not connect the drag handle.
+ connectDragSource: (node) => node
+};
+
+export default DelayProfile;
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css
new file mode 100644
index 000000000..cc5a92830
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css
@@ -0,0 +1,3 @@
+.dragPreview {
+ opacity: 0.75;
+}
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css.d.ts b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css.d.ts
new file mode 100644
index 000000000..1f1f3c320
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'dragPreview': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js
new file mode 100644
index 000000000..1ebb32a95
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js
@@ -0,0 +1,78 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { DragLayer } from 'react-dnd';
+import DragPreviewLayer from 'Components/DragPreviewLayer';
+import { DELAY_PROFILE } from 'Helpers/dragTypes';
+import dimensions from 'Styles/Variables/dimensions.js';
+import DelayProfile from './DelayProfile';
+import styles from './DelayProfileDragPreview.css';
+
+const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
+
+function collectDragLayer(monitor) {
+ return {
+ item: monitor.getItem(),
+ itemType: monitor.getItemType(),
+ currentOffset: monitor.getSourceClientOffset()
+ };
+}
+
+class DelayProfileDragPreview extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ width,
+ item,
+ itemType,
+ currentOffset
+ } = this.props;
+
+ if (!currentOffset || itemType !== DELAY_PROFILE) {
+ return null;
+ }
+
+ // The offset is shifted because the drag handle is on the right edge of the
+ // list item and the preview is wider than the drag handle.
+
+ const { x, y } = currentOffset;
+ const handleOffset = width - dragHandleWidth;
+ const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
+
+ const style = {
+ width,
+ position: 'absolute',
+ WebkitTransform: transform,
+ msTransform: transform,
+ transform
+ };
+
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+DelayProfileDragPreview.propTypes = {
+ width: PropTypes.number.isRequired,
+ item: PropTypes.object,
+ itemType: PropTypes.string,
+ currentOffset: PropTypes.shape({
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired
+ })
+};
+
+export default DragLayer(collectDragLayer)(DelayProfileDragPreview);
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css
new file mode 100644
index 000000000..835250678
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css
@@ -0,0 +1,17 @@
+.delayProfileDragSource {
+ padding: 4px 0;
+}
+
+.delayProfilePlaceholder {
+ width: 100%;
+ height: 30px;
+ border-bottom: 1px dotted #aaa;
+}
+
+.delayProfilePlaceholderBefore {
+ margin-bottom: 8px;
+}
+
+.delayProfilePlaceholderAfter {
+ margin-top: 8px;
+}
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css.d.ts b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css.d.ts
new file mode 100644
index 000000000..0554ea7e8
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css.d.ts
@@ -0,0 +1,10 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'delayProfileDragSource': string;
+ 'delayProfilePlaceholder': string;
+ 'delayProfilePlaceholderAfter': string;
+ 'delayProfilePlaceholderBefore': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js
new file mode 100644
index 000000000..8bf739ceb
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js
@@ -0,0 +1,148 @@
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { DragSource, DropTarget } from 'react-dnd';
+import { findDOMNode } from 'react-dom';
+import { DELAY_PROFILE } from 'Helpers/dragTypes';
+import DelayProfile from './DelayProfile';
+import styles from './DelayProfileDragSource.css';
+
+const delayProfileDragSource = {
+ beginDrag(item) {
+ return item;
+ },
+
+ endDrag(props, monitor, component) {
+ props.onDelayProfileDragEnd(monitor.getItem(), monitor.didDrop());
+ }
+};
+
+const delayProfileDropTarget = {
+ hover(props, monitor, component) {
+ const dragIndex = monitor.getItem().order;
+ const hoverIndex = props.order;
+
+ const hoverBoundingRect = findDOMNode(component).getBoundingClientRect();
+ const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
+ const clientOffset = monitor.getClientOffset();
+ const hoverClientY = clientOffset.y - hoverBoundingRect.top;
+
+ if (dragIndex === hoverIndex) {
+ return;
+ }
+
+ // When moving up, only trigger if drag position is above 50% and
+ // when moving down, only trigger if drag position is below 50%.
+ // If we're moving down the hoverIndex needs to be increased
+ // by one so it's ordered properly. Otherwise the hoverIndex will work.
+
+ if (dragIndex < hoverIndex && hoverClientY > hoverMiddleY) {
+ props.onDelayProfileDragMove(dragIndex, hoverIndex + 1);
+ } else if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) {
+ props.onDelayProfileDragMove(dragIndex, hoverIndex);
+ }
+ }
+};
+
+function collectDragSource(connect, monitor) {
+ return {
+ connectDragSource: connect.dragSource(),
+ isDragging: monitor.isDragging()
+ };
+}
+
+function collectDropTarget(connect, monitor) {
+ return {
+ connectDropTarget: connect.dropTarget(),
+ isOver: monitor.isOver()
+ };
+}
+
+class DelayProfileDragSource extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ order,
+ isDragging,
+ isDraggingUp,
+ isDraggingDown,
+ isOver,
+ connectDragSource,
+ connectDropTarget,
+ ...otherProps
+ } = this.props;
+
+ const isBefore = !isDragging && isDraggingUp && isOver;
+ const isAfter = !isDragging && isDraggingDown && isOver;
+
+ // if (isDragging && !isOver) {
+ // return null;
+ // }
+
+ return connectDropTarget(
+
+ {
+ isBefore &&
+
+ }
+
+
+
+ {
+ isAfter &&
+
+ }
+
+ );
+ }
+}
+
+DelayProfileDragSource.propTypes = {
+ id: PropTypes.number.isRequired,
+ order: PropTypes.number.isRequired,
+ isDragging: PropTypes.bool,
+ isDraggingUp: PropTypes.bool,
+ isDraggingDown: PropTypes.bool,
+ isOver: PropTypes.bool,
+ connectDragSource: PropTypes.func,
+ connectDropTarget: PropTypes.func,
+ onDelayProfileDragMove: PropTypes.func.isRequired,
+ onDelayProfileDragEnd: PropTypes.func.isRequired
+};
+
+export default DropTarget(
+ DELAY_PROFILE,
+ delayProfileDropTarget,
+ collectDropTarget
+)(DragSource(
+ DELAY_PROFILE,
+ delayProfileDragSource,
+ collectDragSource
+)(DelayProfileDragSource));
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfiles.css b/frontend/src/Settings/Profiles/Delay/DelayProfiles.css
new file mode 100644
index 000000000..efc0b14fa
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfiles.css
@@ -0,0 +1,38 @@
+.horizontalScroll {
+ overflow-x: auto;
+}
+
+.delayProfiles {
+ user-select: none;
+}
+
+.delayProfilesHeader {
+ display: flex;
+ margin-bottom: 10px;
+ font-weight: bold;
+}
+
+.column {
+ flex: 0 0 200px;
+}
+
+.tags {
+ flex: 1 0 auto;
+}
+
+.addDelayProfile {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.addButton {
+ width: $dragHandleWidth;
+ text-align: center;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .horizontalScroll {
+ overflow-y: hidden;
+ width: 100%;
+ }
+}
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfiles.css.d.ts b/frontend/src/Settings/Profiles/Delay/DelayProfiles.css.d.ts
new file mode 100644
index 000000000..3b7f0ee69
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfiles.css.d.ts
@@ -0,0 +1,13 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'addButton': string;
+ 'addDelayProfile': string;
+ 'column': string;
+ 'delayProfiles': string;
+ 'delayProfilesHeader': string;
+ 'horizontalScroll': string;
+ 'tags': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfiles.js b/frontend/src/Settings/Profiles/Delay/DelayProfiles.js
new file mode 100644
index 000000000..83ac09bf8
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfiles.js
@@ -0,0 +1,169 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import Measure from 'Components/Measure';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import Scroller from 'Components/Scroller/Scroller';
+import { icons, scrollDirections } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import DelayProfile from './DelayProfile';
+import DelayProfileDragPreview from './DelayProfileDragPreview';
+import DelayProfileDragSource from './DelayProfileDragSource';
+import EditDelayProfileModalConnector from './EditDelayProfileModalConnector';
+import styles from './DelayProfiles.css';
+
+class DelayProfiles extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddDelayProfileModalOpen: false,
+ width: 0
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddDelayProfilePress = () => {
+ this.setState({ isAddDelayProfileModalOpen: true });
+ };
+
+ onModalClose = () => {
+ this.setState({ isAddDelayProfileModalOpen: false });
+ };
+
+ onMeasure = ({ width }) => {
+ this.setState({ width });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ defaultProfile,
+ items,
+ tagList,
+ dragIndex,
+ dropIndex,
+ onConfirmDeleteDelayProfile,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isAddDelayProfileModalOpen,
+ width
+ } = this.state;
+
+ const isDragging = dropIndex !== null;
+ const isDraggingUp = isDragging && dropIndex < dragIndex;
+ const isDraggingDown = isDragging && dropIndex > dragIndex;
+
+ return (
+
+
+
+
+
+
+
+ {translate('PreferredProtocol')}
+
+
+ {translate('UsenetDelay')}
+
+
+ {translate('TorrentDelay')}
+
+
+ {translate('Tags')}
+
+
+
+
+ {
+ items.map((item, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+ {
+ defaultProfile ?
+
+
+
:
+ null
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+DelayProfiles.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ defaultProfile: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ dragIndex: PropTypes.number,
+ dropIndex: PropTypes.number,
+ onConfirmDeleteDelayProfile: PropTypes.func.isRequired
+};
+
+export default DelayProfiles;
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js b/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js
new file mode 100644
index 000000000..b2f822a5a
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js
@@ -0,0 +1,105 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { deleteDelayProfile, fetchDelayProfiles, reorderDelayProfile } from 'Store/Actions/settingsActions';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import DelayProfiles from './DelayProfiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.delayProfiles,
+ createTagsSelector(),
+ (delayProfiles, tagList) => {
+ const defaultProfile = _.find(delayProfiles.items, { id: 1 });
+ const items = _.sortBy(_.reject(delayProfiles.items, { id: 1 }), ['order']);
+
+ return {
+ defaultProfile,
+ ...delayProfiles,
+ items,
+ tagList
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchDelayProfiles,
+ deleteDelayProfile,
+ reorderDelayProfile
+};
+
+class DelayProfilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ dragIndex: null,
+ dropIndex: null
+ };
+ }
+
+ componentDidMount() {
+ this.props.fetchDelayProfiles();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteDelayProfile = (id) => {
+ this.props.deleteDelayProfile({ id });
+ };
+
+ onDelayProfileDragMove = (dragIndex, dropIndex) => {
+ if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) {
+ this.setState({
+ dragIndex,
+ dropIndex
+ });
+ }
+ };
+
+ onDelayProfileDragEnd = ({ id }, didDrop) => {
+ const {
+ dropIndex
+ } = this.state;
+
+ if (didDrop && dropIndex !== null) {
+ this.props.reorderDelayProfile({ id, moveIndex: dropIndex - 1 });
+ }
+
+ this.setState({
+ dragIndex: null,
+ dropIndex: null
+ });
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DelayProfilesConnector.propTypes = {
+ fetchDelayProfiles: PropTypes.func.isRequired,
+ deleteDelayProfile: PropTypes.func.isRequired,
+ reorderDelayProfile: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DelayProfilesConnector);
diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js
new file mode 100644
index 000000000..ddcd8cf7f
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import { sizes } from 'Helpers/Props';
+import EditDelayProfileModalContentConnector from './EditDelayProfileModalContentConnector';
+
+function EditDelayProfileModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditDelayProfileModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditDelayProfileModal;
diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js
new file mode 100644
index 000000000..5eb8ce871
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditDelayProfileModal from './EditDelayProfileModal';
+
+function mapStateToProps() {
+ return {};
+}
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditDelayProfileModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'settings.delayProfiles' });
+ this.props.onModalClose();
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditDelayProfileModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(EditDelayProfileModalConnector);
diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css
new file mode 100644
index 000000000..a2b6014df
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css
@@ -0,0 +1,5 @@
+.deleteButton {
+ composes: button from '~Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css.d.ts b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css.d.ts
new file mode 100644
index 000000000..c5f0ef8a7
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'deleteButton': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js
new file mode 100644
index 000000000..0d1225a93
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js
@@ -0,0 +1,266 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Alert from 'Components/Alert';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import { inputTypes, kinds } from 'Helpers/Props';
+import { boolSettingShape, numberSettingShape, tagSettingShape } from 'Helpers/Props/Shapes/settingShape';
+import translate from 'Utilities/String/translate';
+import styles from './EditDelayProfileModalContent.css';
+
+const protocolOptions = [
+ {
+ key: 'preferUsenet',
+ get value() {
+ return translate('PreferUsenet');
+ }
+ },
+ {
+ key: 'preferTorrent',
+ get value() {
+ return translate('PreferTorrent');
+ }
+ },
+ {
+ key: 'onlyUsenet',
+ get value() {
+ return translate('OnlyUsenet');
+ }
+ },
+ {
+ key: 'onlyTorrent',
+ get value() {
+ return translate('OnlyTorrent');
+ }
+ }
+];
+
+function EditDelayProfileModalContent(props) {
+ const {
+ id,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ item,
+ protocol,
+ onInputChange,
+ onProtocolChange,
+ onSavePress,
+ onModalClose,
+ onDeleteDelayProfilePress,
+ ...otherProps
+ } = props;
+
+ const {
+ enableUsenet,
+ enableTorrent,
+ usenetDelay,
+ torrentDelay,
+ bypassIfHighestQuality,
+ bypassIfAboveCustomFormatScore,
+ minimumCustomFormatScore,
+ tags
+ } = item;
+
+ return (
+
+
+ {id ? translate('EditDelayProfile') : translate('AddDelayProfile')}
+
+
+
+ {
+ isFetching ?
+ :
+ null
+ }
+
+ {
+ !isFetching && !!error ?
+
+ {translate('AddDelayProfileError')}
+ :
+ null
+ }
+
+ {
+ !isFetching && !error ?
+ :
+ null
+ }
+
+
+ {
+ id && id > 1 ?
+
+ {translate('Delete')}
+ :
+ null
+ }
+
+
+ {translate('Cancel')}
+
+
+
+ {translate('Save')}
+
+
+
+ );
+}
+
+const delayProfileShape = {
+ enableUsenet: PropTypes.shape(boolSettingShape).isRequired,
+ enableTorrent: PropTypes.shape(boolSettingShape).isRequired,
+ usenetDelay: PropTypes.shape(numberSettingShape).isRequired,
+ torrentDelay: PropTypes.shape(numberSettingShape).isRequired,
+ bypassIfHighestQuality: PropTypes.shape(boolSettingShape).isRequired,
+ bypassIfAboveCustomFormatScore: PropTypes.shape(boolSettingShape).isRequired,
+ minimumCustomFormatScore: PropTypes.shape(numberSettingShape).isRequired,
+ order: PropTypes.shape(numberSettingShape),
+ tags: PropTypes.shape(tagSettingShape).isRequired
+};
+
+EditDelayProfileModalContent.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.shape(delayProfileShape).isRequired,
+ protocol: PropTypes.string.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onProtocolChange: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteDelayProfilePress: PropTypes.func
+};
+
+export default EditDelayProfileModalContent;
diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js
new file mode 100644
index 000000000..a1e3d85a1
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js
@@ -0,0 +1,173 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { saveDelayProfile, setDelayProfileValue } from 'Store/Actions/settingsActions';
+import selectSettings from 'Store/Selectors/selectSettings';
+import EditDelayProfileModalContent from './EditDelayProfileModalContent';
+
+const newDelayProfile = {
+ enableUsenet: true,
+ enableTorrent: true,
+ preferredProtocol: 'usenet',
+ usenetDelay: 0,
+ torrentDelay: 0,
+ bypassIfHighestQuality: false,
+ bypassIfAboveCustomFormatScore: false,
+ minimumCustomFormatScore: 0,
+ tags: []
+};
+
+function createDelayProfileSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings.delayProfiles,
+ (id, delayProfiles) => {
+ const {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ pendingChanges,
+ items
+ } = delayProfiles;
+
+ const profile = id ? _.find(items, { id }) : newDelayProfile;
+ const settings = selectSettings(profile, pendingChanges, saveError);
+
+ return {
+ id,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ item: settings.settings,
+ ...settings
+ };
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createDelayProfileSelector(),
+ (delayProfile) => {
+ const enableUsenet = delayProfile.item.enableUsenet.value;
+ const enableTorrent = delayProfile.item.enableTorrent.value;
+ const preferredProtocol = delayProfile.item.preferredProtocol.value;
+ let protocol = 'preferUsenet';
+
+ if (preferredProtocol === 'usenet') {
+ protocol = 'preferUsenet';
+ } else {
+ protocol = 'preferTorrent';
+ }
+
+ if (!enableUsenet) {
+ protocol = 'onlyTorrent';
+ }
+
+ if (!enableTorrent) {
+ protocol = 'onlyUsenet';
+ }
+
+ return {
+ protocol,
+ ...delayProfile
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setDelayProfileValue,
+ saveDelayProfile
+};
+
+class EditDelayProfileModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.id) {
+ Object.keys(newDelayProfile).forEach((name) => {
+ this.props.setDelayProfileValue({
+ name,
+ value: newDelayProfile[name]
+ });
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setDelayProfileValue({ name, value });
+ };
+
+ onProtocolChange = ({ value }) => {
+ switch (value) {
+ case 'preferUsenet':
+ this.props.setDelayProfileValue({ name: 'enableUsenet', value: true });
+ this.props.setDelayProfileValue({ name: 'enableTorrent', value: true });
+ this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'usenet' });
+ break;
+ case 'preferTorrent':
+ this.props.setDelayProfileValue({ name: 'enableUsenet', value: true });
+ this.props.setDelayProfileValue({ name: 'enableTorrent', value: true });
+ this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'torrent' });
+ break;
+ case 'onlyUsenet':
+ this.props.setDelayProfileValue({ name: 'enableUsenet', value: true });
+ this.props.setDelayProfileValue({ name: 'enableTorrent', value: false });
+ this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'usenet' });
+ break;
+ case 'onlyTorrent':
+ this.props.setDelayProfileValue({ name: 'enableUsenet', value: false });
+ this.props.setDelayProfileValue({ name: 'enableTorrent', value: true });
+ this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'torrent' });
+ break;
+ default:
+ throw Error(`Unknown protocol option: ${value}`);
+ }
+ };
+
+ onSavePress = () => {
+ this.props.saveDelayProfile({ id: this.props.id });
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditDelayProfileModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setDelayProfileValue: PropTypes.func.isRequired,
+ saveDelayProfile: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditDelayProfileModalContentConnector);
diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModal.js b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModal.js
new file mode 100644
index 000000000..b167ae60b
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import { sizes } from 'Helpers/Props';
+import EditMetadataProfileModalContentConnector from './EditMetadataProfileModalContentConnector';
+
+function EditMetadataProfileModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditMetadataProfileModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditMetadataProfileModal;
diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalConnector.js b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalConnector.js
new file mode 100644
index 000000000..cece2c82c
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalConnector.js
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditMetadataProfileModal from './EditMetadataProfileModal';
+
+function mapStateToProps() {
+ return {};
+}
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditMetadataProfileModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'settings.metadataProfiles' });
+ this.props.onModalClose();
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditMetadataProfileModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(EditMetadataProfileModalConnector);
diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.css b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.css
new file mode 100644
index 000000000..74dd1c8b7
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.css
@@ -0,0 +1,3 @@
+.deleteButtonContainer {
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.css.d.ts b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.css.d.ts
new file mode 100644
index 000000000..24232a842
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'deleteButtonContainer': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.js b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.js
new file mode 100644
index 000000000..c6b9c4464
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.js
@@ -0,0 +1,159 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import { inputTypes, kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import PrimaryTypeItems from './PrimaryTypeItems';
+import ReleaseStatusItems from './ReleaseStatusItems';
+import SecondaryTypeItems from './SecondaryTypeItems';
+import styles from './EditMetadataProfileModalContent.css';
+
+function EditMetadataProfileModalContent(props) {
+ const {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ primaryAlbumTypes,
+ secondaryAlbumTypes,
+ item,
+ isInUse,
+ onInputChange,
+ onSavePress,
+ onModalClose,
+ onDeleteMetadataProfilePress,
+ ...otherProps
+ } = props;
+
+ const {
+ id,
+ name,
+ primaryAlbumTypes: itemPrimaryAlbumTypes,
+ secondaryAlbumTypes: itemSecondaryAlbumTypes,
+ releaseStatuses: itemReleaseStatuses
+ } = item;
+
+ return (
+
+
+ {id ? translate('EditMetadataProfile') : translate('AddMetadataProfile')}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+
+ {translate('UnableToAddANewMetadataProfilePleaseTryAgain')}
+
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id &&
+
+
+ {translate('Delete')}
+
+
+ }
+
+
+ {translate('Cancel')}
+
+
+
+ {translate('Save')}
+
+
+
+ );
+}
+
+EditMetadataProfileModalContent.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ primaryAlbumTypes: PropTypes.arrayOf(PropTypes.object).isRequired,
+ secondaryAlbumTypes: PropTypes.arrayOf(PropTypes.object).isRequired,
+ releaseStatuses: PropTypes.arrayOf(PropTypes.object).isRequired,
+ item: PropTypes.object.isRequired,
+ isInUse: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteMetadataProfilePress: PropTypes.func
+};
+
+export default EditMetadataProfileModalContent;
diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContentConnector.js
new file mode 100644
index 000000000..ae9c8f4ce
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContentConnector.js
@@ -0,0 +1,213 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchMetadataProfileSchema, saveMetadataProfile, setMetadataProfileValue } from 'Store/Actions/settingsActions';
+import createProfileInUseSelector from 'Store/Selectors/createProfileInUseSelector';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import EditMetadataProfileModalContent from './EditMetadataProfileModalContent';
+
+function createPrimaryAlbumTypesSelector() {
+ return createSelector(
+ createProviderSettingsSelector('metadataProfiles'),
+ (metadataProfile) => {
+ const primaryAlbumTypes = metadataProfile.item.primaryAlbumTypes;
+ if (!primaryAlbumTypes || !primaryAlbumTypes.value) {
+ return [];
+ }
+
+ return _.reduceRight(primaryAlbumTypes.value, (result, { allowed, albumType }) => {
+ if (allowed) {
+ result.push({
+ key: albumType.id,
+ value: albumType.name
+ });
+ }
+
+ return result;
+ }, []);
+ }
+ );
+}
+
+function createSecondaryAlbumTypesSelector() {
+ return createSelector(
+ createProviderSettingsSelector('metadataProfiles'),
+ (metadataProfile) => {
+ const secondaryAlbumTypes = metadataProfile.item.secondaryAlbumTypes;
+ if (!secondaryAlbumTypes || !secondaryAlbumTypes.value) {
+ return [];
+ }
+
+ return _.reduceRight(secondaryAlbumTypes.value, (result, { allowed, albumType }) => {
+ if (allowed) {
+ result.push({
+ key: albumType.id,
+ value: albumType.name
+ });
+ }
+
+ return result;
+ }, []);
+ }
+ );
+}
+
+function createReleaseStatusesSelector() {
+ return createSelector(
+ createProviderSettingsSelector('metadataProfiles'),
+ (metadataProfile) => {
+ const releaseStatuses = metadataProfile.item.releaseStatuses;
+ if (!releaseStatuses || !releaseStatuses.value) {
+ return [];
+ }
+
+ return _.reduceRight(releaseStatuses.value, (result, { allowed, releaseStatus }) => {
+ if (allowed) {
+ result.push({
+ key: releaseStatus.id,
+ value: releaseStatus.name
+ });
+ }
+
+ return result;
+ }, []);
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createProviderSettingsSelector('metadataProfiles'),
+ createPrimaryAlbumTypesSelector(),
+ createSecondaryAlbumTypesSelector(),
+ createReleaseStatusesSelector(),
+ createProfileInUseSelector('metadataProfileId'),
+ (metadataProfile, primaryAlbumTypes, secondaryAlbumTypes, releaseStatuses, isInUse) => {
+ return {
+ primaryAlbumTypes,
+ secondaryAlbumTypes,
+ releaseStatuses,
+ ...metadataProfile,
+ isInUse
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchMetadataProfileSchema,
+ setMetadataProfileValue,
+ saveMetadataProfile
+};
+
+class EditMetadataProfileModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ dragIndex: null,
+ dropIndex: null
+ };
+ }
+
+ componentDidMount() {
+ if (!this.props.id && !this.props.isPopulated) {
+ this.props.fetchMetadataProfileSchema();
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setMetadataProfileValue({ name, value });
+ };
+
+ onSavePress = () => {
+ this.props.saveMetadataProfile({ id: this.props.id });
+ };
+
+ onMetadataPrimaryTypeItemAllowedChange = (id, allowed) => {
+ const metadataProfile = _.cloneDeep(this.props.item);
+
+ const item = _.find(metadataProfile.primaryAlbumTypes.value, (i) => i.albumType.id === id);
+ item.allowed = allowed;
+
+ this.props.setMetadataProfileValue({
+ name: 'primaryAlbumTypes',
+ value: metadataProfile.primaryAlbumTypes.value
+ });
+ };
+
+ onMetadataSecondaryTypeItemAllowedChange = (id, allowed) => {
+ const metadataProfile = _.cloneDeep(this.props.item);
+
+ const item = _.find(metadataProfile.secondaryAlbumTypes.value, (i) => i.albumType.id === id);
+ item.allowed = allowed;
+
+ this.props.setMetadataProfileValue({
+ name: 'secondaryAlbumTypes',
+ value: metadataProfile.secondaryAlbumTypes.value
+ });
+ };
+
+ onMetadataReleaseStatusItemAllowedChange = (id, allowed) => {
+ const metadataProfile = _.cloneDeep(this.props.item);
+
+ const item = _.find(metadataProfile.releaseStatuses.value, (i) => i.releaseStatus.id === id);
+ item.allowed = allowed;
+
+ this.props.setMetadataProfileValue({
+ name: 'releaseStatuses',
+ value: metadataProfile.releaseStatuses.value
+ });
+ };
+
+ //
+ // Render
+
+ render() {
+ if (_.isEmpty(this.props.item.primaryAlbumTypes) && !this.props.isFetching) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+}
+
+EditMetadataProfileModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setMetadataProfileValue: PropTypes.func.isRequired,
+ fetchMetadataProfileSchema: PropTypes.func.isRequired,
+ saveMetadataProfile: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditMetadataProfileModalContentConnector);
diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfile.css b/frontend/src/Settings/Profiles/Metadata/MetadataProfile.css
new file mode 100644
index 000000000..880788343
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfile.css
@@ -0,0 +1,31 @@
+.metadataProfile {
+ composes: card from '~Components/Card.css';
+
+ width: 300px;
+}
+
+.nameContainer {
+ display: flex;
+ justify-content: space-between;
+}
+
+.name {
+ @add-mixin truncate;
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.cloneButton {
+ composes: button from '~Components/Link/IconButton.css';
+
+ height: 36px;
+}
+
+.albumTypes {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+ pointer-events: all;
+}
diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfile.css.d.ts b/frontend/src/Settings/Profiles/Metadata/MetadataProfile.css.d.ts
new file mode 100644
index 000000000..6d3f21d57
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfile.css.d.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'albumTypes': string;
+ 'cloneButton': string;
+ 'metadataProfile': string;
+ 'name': string;
+ 'nameContainer': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfile.js b/frontend/src/Settings/Profiles/Metadata/MetadataProfile.js
new file mode 100644
index 000000000..c3cfefa1d
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfile.js
@@ -0,0 +1,165 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import IconButton from 'Components/Link/IconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import { icons, kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import EditMetadataProfileModalConnector from './EditMetadataProfileModalConnector';
+import styles from './MetadataProfile.css';
+
+class MetadataProfile extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditMetadataProfileModalOpen: false,
+ isDeleteMetadataProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditMetadataProfilePress = () => {
+ this.setState({ isEditMetadataProfileModalOpen: true });
+ };
+
+ onEditMetadataProfileModalClose = () => {
+ this.setState({ isEditMetadataProfileModalOpen: false });
+ };
+
+ onDeleteMetadataProfilePress = () => {
+ this.setState({
+ isEditMetadataProfileModalOpen: false,
+ isDeleteMetadataProfileModalOpen: true
+ });
+ };
+
+ onDeleteMetadataProfileModalClose = () => {
+ this.setState({ isDeleteMetadataProfileModalOpen: false });
+ };
+
+ onConfirmDeleteMetadataProfile = () => {
+ this.props.onConfirmDeleteMetadataProfile(this.props.id);
+ };
+
+ onCloneMetadataProfilePress = () => {
+ const {
+ id,
+ onCloneMetadataProfilePress
+ } = this.props;
+
+ onCloneMetadataProfilePress(id);
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ primaryAlbumTypes,
+ secondaryAlbumTypes,
+ isDeleting
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ primaryAlbumTypes.map((item) => {
+ if (!item.allowed) {
+ return null;
+ }
+
+ return (
+
+ {item.albumType.name}
+
+ );
+ })
+ }
+
+
+
+ {
+ secondaryAlbumTypes.map((item) => {
+ if (!item.allowed) {
+ return null;
+ }
+
+ return (
+
+ {item.albumType.name}
+
+ );
+ })
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+MetadataProfile.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ primaryAlbumTypes: PropTypes.arrayOf(PropTypes.object).isRequired,
+ secondaryAlbumTypes: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDeleting: PropTypes.bool.isRequired,
+ onConfirmDeleteMetadataProfile: PropTypes.func.isRequired,
+ onCloneMetadataProfilePress: PropTypes.func.isRequired
+
+};
+
+export default MetadataProfile;
diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.css b/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.css
new file mode 100644
index 000000000..f5b69a80e
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.css
@@ -0,0 +1,21 @@
+.metadataProfiles {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addMetadataProfile {
+ composes: metadataProfile from '~./MetadataProfile.css';
+
+ background-color: var(--cardAlternateBackgroundColor);
+ color: var(--gray);
+ text-align: center;
+ font-size: 45px;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid var(--borderColor);
+ border-radius: 4px;
+ background-color: var(--cardCenterBackgroundColor);
+}
diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.css.d.ts b/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.css.d.ts
new file mode 100644
index 000000000..c9469d5f3
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'addMetadataProfile': string;
+ 'center': string;
+ 'metadataProfiles': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.js b/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.js
new file mode 100644
index 000000000..5e719517f
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.js
@@ -0,0 +1,111 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Card from 'Components/Card';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import { icons, metadataProfileNames } from 'Helpers/Props';
+import sortByProp from 'Utilities/Array/sortByProp';
+import translate from 'Utilities/String/translate';
+import EditMetadataProfileModalConnector from './EditMetadataProfileModalConnector';
+import MetadataProfile from './MetadataProfile';
+import styles from './MetadataProfiles.css';
+
+class MetadataProfiles extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isMetadataProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onCloneMetadataProfilePress = (id) => {
+ this.props.onCloneMetadataProfilePress(id);
+ this.setState({ isMetadataProfileModalOpen: true });
+ };
+
+ onEditMetadataProfilePress = () => {
+ this.setState({ isMetadataProfileModalOpen: true });
+ };
+
+ onModalClose = () => {
+ this.setState({ isMetadataProfileModalOpen: false });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ isDeleting,
+ onConfirmDeleteMetadataProfile,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ {
+ items
+ .filter((item) => item.name !== metadataProfileNames.NONE)
+ .sort(sortByProp('name'))
+ .map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+MetadataProfiles.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDeleting: PropTypes.bool.isRequired,
+ onConfirmDeleteMetadataProfile: PropTypes.func.isRequired,
+ onCloneMetadataProfilePress: PropTypes.func.isRequired
+};
+
+export default MetadataProfiles;
diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfilesConnector.js b/frontend/src/Settings/Profiles/Metadata/MetadataProfilesConnector.js
new file mode 100644
index 000000000..b4c8c2037
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfilesConnector.js
@@ -0,0 +1,67 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { cloneMetadataProfile, deleteMetadataProfile, fetchMetadataProfiles } from 'Store/Actions/settingsActions';
+import MetadataProfiles from './MetadataProfiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ (state) => state.settings.metadataProfiles,
+ (advancedSettings, metadataProfiles) => {
+ return {
+ advancedSettings,
+ ...metadataProfiles
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchMetadataProfiles: fetchMetadataProfiles,
+ dispatchDeleteMetadataProfile: deleteMetadataProfile,
+ dispatchCloneMetadataProfile: cloneMetadataProfile
+};
+
+class MetadataProfilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchMetadataProfiles();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteMetadataProfile = (id) => {
+ this.props.dispatchDeleteMetadataProfile({ id });
+ };
+
+ onCloneMetadataProfilePress = (id) => {
+ this.props.dispatchCloneMetadataProfile({ id });
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MetadataProfilesConnector.propTypes = {
+ dispatchFetchMetadataProfiles: PropTypes.func.isRequired,
+ dispatchDeleteMetadataProfile: PropTypes.func.isRequired,
+ dispatchCloneMetadataProfile: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(MetadataProfilesConnector);
diff --git a/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItem.js b/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItem.js
new file mode 100644
index 000000000..551eb999c
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItem.js
@@ -0,0 +1,60 @@
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import CheckInput from 'Components/Form/CheckInput';
+import styles from './TypeItem.css';
+
+class PrimaryTypeItem extends Component {
+
+ //
+ // Listeners
+
+ onAllowedChange = ({ value }) => {
+ const {
+ albumTypeId,
+ onMetadataPrimaryTypeItemAllowedChange
+ } = this.props;
+
+ onMetadataPrimaryTypeItemAllowedChange(albumTypeId, value);
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ allowed
+ } = this.props;
+
+ return (
+
+
+
+ {name}
+
+
+ );
+ }
+}
+
+PrimaryTypeItem.propTypes = {
+ albumTypeId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ sortIndex: PropTypes.number.isRequired,
+ onMetadataPrimaryTypeItemAllowedChange: PropTypes.func
+};
+
+export default PrimaryTypeItem;
diff --git a/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItems.js b/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItems.js
new file mode 100644
index 000000000..e70b831b3
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItems.js
@@ -0,0 +1,90 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputHelpText from 'Components/Form/FormInputHelpText';
+import FormLabel from 'Components/Form/FormLabel';
+import translate from 'Utilities/String/translate';
+import PrimaryTypeItem from './PrimaryTypeItem';
+import styles from './TypeItems.css';
+
+class PrimaryTypeItems extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ metadataProfileItems,
+ errors,
+ warnings,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+ {translate('PrimaryTypes')}
+
+
+
+ {
+ errors.map((error, index) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ warnings.map((warning, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+ {
+ metadataProfileItems.map(({ allowed, albumType }, index) => {
+ return (
+
+ );
+ }).reverse()
+ }
+
+
+
+ );
+ }
+}
+
+PrimaryTypeItems.propTypes = {
+ metadataProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired,
+ errors: PropTypes.arrayOf(PropTypes.object),
+ warnings: PropTypes.arrayOf(PropTypes.object),
+ formLabel: PropTypes.string
+};
+
+PrimaryTypeItems.defaultProps = {
+ errors: [],
+ warnings: []
+};
+
+export default PrimaryTypeItems;
diff --git a/frontend/src/Settings/Profiles/Metadata/ReleaseStatusItem.js b/frontend/src/Settings/Profiles/Metadata/ReleaseStatusItem.js
new file mode 100644
index 000000000..a80c72f6a
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/ReleaseStatusItem.js
@@ -0,0 +1,60 @@
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import CheckInput from 'Components/Form/CheckInput';
+import styles from './TypeItem.css';
+
+class ReleaseStatusItem extends Component {
+
+ //
+ // Listeners
+
+ onAllowedChange = ({ value }) => {
+ const {
+ albumTypeId,
+ onMetadataReleaseStatusItemAllowedChange
+ } = this.props;
+
+ onMetadataReleaseStatusItemAllowedChange(albumTypeId, value);
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ allowed
+ } = this.props;
+
+ return (
+
+
+
+ {name}
+
+
+ );
+ }
+}
+
+ReleaseStatusItem.propTypes = {
+ albumTypeId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ sortIndex: PropTypes.number.isRequired,
+ onMetadataReleaseStatusItemAllowedChange: PropTypes.func
+};
+
+export default ReleaseStatusItem;
diff --git a/frontend/src/Settings/Profiles/Metadata/ReleaseStatusItems.js b/frontend/src/Settings/Profiles/Metadata/ReleaseStatusItems.js
new file mode 100644
index 000000000..8fbea3499
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/ReleaseStatusItems.js
@@ -0,0 +1,90 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputHelpText from 'Components/Form/FormInputHelpText';
+import FormLabel from 'Components/Form/FormLabel';
+import translate from 'Utilities/String/translate';
+import ReleaseStatusItem from './ReleaseStatusItem';
+import styles from './TypeItems.css';
+
+class ReleaseStatusItems extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ metadataProfileItems,
+ errors,
+ warnings,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+ {translate('ReleaseStatuses')}
+
+
+
+ {
+ errors.map((error, index) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ warnings.map((warning, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+ {
+ metadataProfileItems.map(({ allowed, releaseStatus }, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ );
+ }
+}
+
+ReleaseStatusItems.propTypes = {
+ metadataProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired,
+ errors: PropTypes.arrayOf(PropTypes.object),
+ warnings: PropTypes.arrayOf(PropTypes.object),
+ formLabel: PropTypes.string
+};
+
+ReleaseStatusItems.defaultProps = {
+ errors: [],
+ warnings: []
+};
+
+export default ReleaseStatusItems;
diff --git a/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItem.js b/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItem.js
new file mode 100644
index 000000000..52fdd0192
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItem.js
@@ -0,0 +1,60 @@
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import CheckInput from 'Components/Form/CheckInput';
+import styles from './TypeItem.css';
+
+class SecondaryTypeItem extends Component {
+
+ //
+ // Listeners
+
+ onAllowedChange = ({ value }) => {
+ const {
+ albumTypeId,
+ onMetadataSecondaryTypeItemAllowedChange
+ } = this.props;
+
+ onMetadataSecondaryTypeItemAllowedChange(albumTypeId, value);
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ allowed
+ } = this.props;
+
+ return (
+
+
+
+ {name}
+
+
+ );
+ }
+}
+
+SecondaryTypeItem.propTypes = {
+ albumTypeId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ sortIndex: PropTypes.number.isRequired,
+ onMetadataSecondaryTypeItemAllowedChange: PropTypes.func
+};
+
+export default SecondaryTypeItem;
diff --git a/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItems.js b/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItems.js
new file mode 100644
index 000000000..24ca76cf3
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItems.js
@@ -0,0 +1,90 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputHelpText from 'Components/Form/FormInputHelpText';
+import FormLabel from 'Components/Form/FormLabel';
+import translate from 'Utilities/String/translate';
+import SecondaryTypeItem from './SecondaryTypeItem';
+import styles from './TypeItems.css';
+
+class SecondaryTypeItems extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ metadataProfileItems,
+ errors,
+ warnings,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+ {translate('SecondaryTypes')}
+
+
+
+ {
+ errors.map((error, index) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ warnings.map((warning, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+ {
+ metadataProfileItems.map(({ allowed, albumType }, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ );
+ }
+}
+
+SecondaryTypeItems.propTypes = {
+ metadataProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired,
+ errors: PropTypes.arrayOf(PropTypes.object),
+ warnings: PropTypes.arrayOf(PropTypes.object),
+ formLabel: PropTypes.string
+};
+
+SecondaryTypeItems.defaultProps = {
+ errors: [],
+ warnings: []
+};
+
+export default SecondaryTypeItems;
diff --git a/frontend/src/Settings/Profiles/Metadata/TypeItem.css b/frontend/src/Settings/Profiles/Metadata/TypeItem.css
new file mode 100644
index 000000000..908f3bde6
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/TypeItem.css
@@ -0,0 +1,25 @@
+.metadataProfileItem {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+}
+
+.checkContainer {
+ position: relative;
+ margin-right: 4px;
+ margin-bottom: 7px;
+ margin-left: 8px;
+}
+
+.albumTypeName {
+ display: flex;
+ flex-grow: 1;
+ margin-bottom: 0;
+ margin-left: 2px;
+ font-weight: normal;
+ line-height: 36px;
+}
+
+.isDragging {
+ opacity: 0.25;
+}
diff --git a/frontend/src/Settings/Profiles/Metadata/TypeItem.css.d.ts b/frontend/src/Settings/Profiles/Metadata/TypeItem.css.d.ts
new file mode 100644
index 000000000..618c15cee
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/TypeItem.css.d.ts
@@ -0,0 +1,10 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'albumTypeName': string;
+ 'checkContainer': string;
+ 'isDragging': string;
+ 'metadataProfileItem': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Metadata/TypeItems.css b/frontend/src/Settings/Profiles/Metadata/TypeItems.css
new file mode 100644
index 000000000..3bce22799
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/TypeItems.css
@@ -0,0 +1,6 @@
+.albumTypes {
+ margin-top: 10px;
+ /* TODO: This should consider the number of types in the list */
+ min-height: 200px;
+ user-select: none;
+}
diff --git a/frontend/src/Settings/Profiles/Metadata/TypeItems.css.d.ts b/frontend/src/Settings/Profiles/Metadata/TypeItems.css.d.ts
new file mode 100644
index 000000000..5ab3f9ea1
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/TypeItems.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'albumTypes': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Profiles.js b/frontend/src/Settings/Profiles/Profiles.js
new file mode 100644
index 000000000..985214fc1
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Profiles.js
@@ -0,0 +1,41 @@
+import React, { Component } from 'react';
+import { DndProvider } from 'react-dnd-multi-backend';
+import HTML5toTouch from 'react-dnd-multi-backend/dist/esm/HTML5toTouch';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import translate from 'Utilities/String/translate';
+import DelayProfilesConnector from './Delay/DelayProfilesConnector';
+import MetadataProfilesConnector from './Metadata/MetadataProfilesConnector';
+import QualityProfilesConnector from './Quality/QualityProfilesConnector';
+import ReleaseProfilesConnector from './Release/ReleaseProfilesConnector';
+
+// Only a single DragDrop Context can exist so it's done here to allow editing
+// quality profiles and reordering delay profiles to work.
+
+class Profiles extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default Profiles;
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js
new file mode 100644
index 000000000..d6f2b8ed0
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js
@@ -0,0 +1,61 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import { sizes } from 'Helpers/Props';
+import EditQualityProfileModalContentConnector from './EditQualityProfileModalContentConnector';
+
+class EditQualityProfileModal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ height: 'auto'
+ };
+ }
+
+ //
+ // Listeners
+
+ onContentHeightChange = (height) => {
+ if (this.state.height === 'auto' || height > this.state.height) {
+ this.setState({ height });
+ }
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+EditQualityProfileModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditQualityProfileModal;
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js
new file mode 100644
index 000000000..5d7f48d29
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditQualityProfileModal from './EditQualityProfileModal';
+
+function mapStateToProps() {
+ return {};
+}
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditQualityProfileModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'settings.qualityProfiles' });
+ this.props.onModalClose();
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditQualityProfileModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(EditQualityProfileModalConnector);
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css
new file mode 100644
index 000000000..586f99e70
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css
@@ -0,0 +1,31 @@
+.formGroupsContainer {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.formGroupWrapper,
+.formatItemLarge {
+ flex: 0 0 calc($formGroupSmallWidth - 100px);
+}
+
+.deleteButtonContainer {
+ margin-right: auto;
+}
+
+.formatItemSmall {
+ display: none;
+}
+
+@media only screen and (max-width: calc($breakpointLarge + 100px)) {
+ .formGroupsContainer {
+ display: block;
+ }
+
+ .formatItemSmall {
+ display: block;
+ }
+
+ .formatItemLarge {
+ display: none;
+ }
+}
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css.d.ts b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css.d.ts
new file mode 100644
index 000000000..689c2e723
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css.d.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'deleteButtonContainer': string;
+ 'formGroupWrapper': string;
+ 'formGroupsContainer': string;
+ 'formatItemLarge': string;
+ 'formatItemSmall': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js
new file mode 100644
index 000000000..69b18a021
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js
@@ -0,0 +1,331 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Measure from 'Components/Measure';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import { inputTypes, kinds, sizes } from 'Helpers/Props';
+import dimensions from 'Styles/Variables/dimensions';
+import translate from 'Utilities/String/translate';
+import QualityProfileFormatItems from './QualityProfileFormatItems';
+import QualityProfileItems from './QualityProfileItems';
+import styles from './EditQualityProfileModalContent.css';
+
+const MODAL_BODY_PADDING = parseInt(dimensions.modalBodyPadding);
+
+function getCustomFormatRender(formatItems, otherProps) {
+ return (
+
+ );
+}
+
+class EditQualityProfileModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ headerHeight: 0,
+ bodyHeight: 0,
+ footerHeight: 0
+ };
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const {
+ headerHeight,
+ bodyHeight,
+ footerHeight
+ } = this.state;
+
+ if (
+ headerHeight > 0 &&
+ bodyHeight > 0 &&
+ footerHeight > 0 &&
+ (
+ headerHeight !== prevState.headerHeight ||
+ bodyHeight !== prevState.bodyHeight ||
+ footerHeight !== prevState.footerHeight
+ )
+ ) {
+ const padding = MODAL_BODY_PADDING * 2;
+
+ this.props.onContentHeightChange(
+ headerHeight + bodyHeight + footerHeight + padding
+ );
+ }
+ }
+
+ //
+ // Listeners
+
+ onHeaderMeasure = ({ height }) => {
+ if (height > this.state.headerHeight) {
+ this.setState({ headerHeight: height });
+ }
+ };
+
+ onBodyMeasure = ({ height }) => {
+
+ if (height > this.state.bodyHeight) {
+ this.setState({ bodyHeight: height });
+ }
+ };
+
+ onFooterMeasure = ({ height }) => {
+ if (height > this.state.footerHeight) {
+ this.setState({ footerHeight: height });
+ }
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ editGroups,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ qualities,
+ customFormats,
+ item,
+ isInUse,
+ onInputChange,
+ onCutoffChange,
+ onSavePress,
+ onModalClose,
+ onDeleteQualityProfilePress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ id,
+ name,
+ upgradeAllowed,
+ cutoff,
+ minFormatScore,
+ cutoffFormatScore,
+ items,
+ formatItems
+ } = item;
+
+ return (
+
+
+
+ {id ? translate('EditQualityProfile') : translate('AddQualityProfile')}
+
+
+
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+
+ {translate('UnableToAddANewQualityProfilePleaseTryAgain')}
+
+ }
+
+ {
+ !isFetching && !error &&
+
+
+ }
+
+
+
+
+
+
+ {
+ id ?
+
+
+ {translate('Delete')}
+
+
:
+ null
+ }
+
+
+ {translate('Cancel')}
+
+
+
+ {translate('Save')}
+
+
+
+
+ );
+ }
+}
+
+EditQualityProfileModalContent.propTypes = {
+ editGroups: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ qualities: PropTypes.arrayOf(PropTypes.object).isRequired,
+ customFormats: PropTypes.arrayOf(PropTypes.object).isRequired,
+ item: PropTypes.object.isRequired,
+ isInUse: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onCutoffChange: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onContentHeightChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteQualityProfilePress: PropTypes.func
+};
+
+export default EditQualityProfileModalContent;
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js
new file mode 100644
index 000000000..de92e998b
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js
@@ -0,0 +1,488 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchQualityProfileSchema, saveQualityProfile, setQualityProfileValue } from 'Store/Actions/settingsActions';
+import createProfileInUseSelector from 'Store/Selectors/createProfileInUseSelector';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import EditQualityProfileModalContent from './EditQualityProfileModalContent';
+
+function getQualityItemGroupId(qualityProfile) {
+ // Get items with an `id` and filter out null/undefined values
+ const ids = _.filter(_.map(qualityProfile.items.value, 'id'), (i) => i != null);
+
+ return Math.max(1000, ...ids) + 1;
+}
+
+function parseIndex(index) {
+ const split = index.split('.');
+
+ if (split.length === 1) {
+ return [
+ null,
+ parseInt(split[0]) - 1
+ ];
+ }
+
+ return [
+ parseInt(split[0]) - 1,
+ parseInt(split[1]) - 1
+ ];
+}
+
+function createQualitiesSelector() {
+ return createSelector(
+ createProviderSettingsSelector('qualityProfiles'),
+ (qualityProfile) => {
+ const items = qualityProfile.item.items;
+ if (!items || !items.value) {
+ return [];
+ }
+
+ return _.reduceRight(items.value, (result, { allowed, id, name, quality }) => {
+ if (allowed) {
+ if (id) {
+ result.push({
+ key: id,
+ value: name
+ });
+ } else {
+ result.push({
+ key: quality.id,
+ value: quality.name
+ });
+ }
+ }
+
+ return result;
+ }, []);
+ }
+ );
+}
+
+function createFormatsSelector() {
+ return createSelector(
+ createProviderSettingsSelector('qualityProfiles'),
+ (customFormat) => {
+ const items = customFormat.item.formatItems;
+ if (!items || !items.value) {
+ return [];
+ }
+
+ return _.reduceRight(items.value, (result, { id, name, format, score }) => {
+ if (id) {
+ result.push({
+ key: id,
+ value: name,
+ score
+ });
+ } else {
+ result.push({
+ key: format,
+ value: name,
+ score
+ });
+ }
+
+ return result;
+ }, []);
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createProviderSettingsSelector('qualityProfiles'),
+ createQualitiesSelector(),
+ createFormatsSelector(),
+ createProfileInUseSelector('qualityProfileId'),
+ (qualityProfile, qualities, customFormats, isInUse) => {
+ return {
+ qualities,
+ customFormats,
+ ...qualityProfile,
+ isInUse
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchQualityProfileSchema,
+ setQualityProfileValue,
+ saveQualityProfile
+};
+
+class EditQualityProfileModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ dragQualityIndex: null,
+ dropQualityIndex: null,
+ dropPosition: null,
+ editGroups: false
+ };
+ }
+
+ componentDidMount() {
+ if (!this.props.id && !this.props.isPopulated) {
+ this.props.fetchQualityProfileSchema();
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Control
+
+ ensureCutoff = (qualityProfile) => {
+ const cutoff = qualityProfile.cutoff.value;
+
+ const cutoffItem = _.find(qualityProfile.items.value, (i) => {
+ if (!cutoff) {
+ return false;
+ }
+
+ return i.id === cutoff || (i.quality && i.quality.id === cutoff);
+ });
+
+ // If the cutoff isn't allowed anymore or there isn't a cutoff set one
+ if (!cutoff || !cutoffItem || !cutoffItem.allowed) {
+ const firstAllowed = _.find(qualityProfile.items.value, { allowed: true });
+ let cutoffId = null;
+
+ if (firstAllowed) {
+ cutoffId = firstAllowed.quality ? firstAllowed.quality.id : firstAllowed.id;
+ }
+
+ this.props.setQualityProfileValue({ name: 'cutoff', value: cutoffId });
+ }
+ };
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setQualityProfileValue({ name, value });
+ };
+
+ onCutoffChange = ({ name, value }) => {
+ const id = parseInt(value);
+ const item = _.find(this.props.item.items.value, (i) => {
+ if (i.quality) {
+ return i.quality.id === id;
+ }
+
+ return i.id === id;
+ });
+
+ const cutoffId = item.quality ? item.quality.id : item.id;
+
+ this.props.setQualityProfileValue({ name, value: cutoffId });
+ };
+
+ onSavePress = () => {
+ this.props.saveQualityProfile({ id: this.props.id });
+ };
+
+ onQualityProfileItemAllowedChange = (id, allowed) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const item = _.find(qualityProfile.items.value, (i) => i.quality && i.quality.id === id);
+
+ item.allowed = allowed;
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+
+ this.ensureCutoff(qualityProfile);
+ };
+
+ onQualityProfileFormatItemScoreChange = (id, score) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const formatItems = qualityProfile.formatItems.value;
+ const item = _.find(qualityProfile.formatItems.value, (i) => i.format === id);
+
+ item.score = score;
+
+ this.props.setQualityProfileValue({
+ name: 'formatItems',
+ value: formatItems
+ });
+ };
+
+ onItemGroupAllowedChange = (id, allowed) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const item = _.find(qualityProfile.items.value, (i) => i.id === id);
+
+ item.allowed = allowed;
+
+ // Update each item in the group (for consistency only)
+ item.items.forEach((i) => {
+ i.allowed = allowed;
+ });
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+
+ this.ensureCutoff(qualityProfile);
+ };
+
+ onItemGroupNameChange = (id, name) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const group = _.find(items, (i) => i.id === id);
+
+ group.name = name;
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+ };
+
+ onCreateGroupPress = (id) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const item = _.find(items, (i) => i.quality && i.quality.id === id);
+ const index = items.indexOf(item);
+ const groupId = getQualityItemGroupId(qualityProfile);
+
+ const group = {
+ id: groupId,
+ name: item.quality.name,
+ allowed: item.allowed,
+ items: [
+ item
+ ]
+ };
+
+ // Add the group in the same location the quality item was in.
+ items.splice(index, 1, group);
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+
+ this.ensureCutoff(qualityProfile);
+ };
+
+ onDeleteGroupPress = (id) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const group = _.find(items, (i) => i.id === id);
+ const index = items.indexOf(group);
+
+ // Add the items in the same location the group was in
+ items.splice(index, 1, ...group.items);
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+
+ this.ensureCutoff(qualityProfile);
+ };
+
+ onQualityProfileItemDragMove = (options) => {
+ const {
+ dragQualityIndex,
+ dropQualityIndex,
+ dropPosition
+ } = options;
+
+ const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex);
+ const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex);
+
+ if (
+ (dropPosition === 'below' && dropItemIndex - 1 === dragItemIndex) ||
+ (dropPosition === 'above' && dropItemIndex + 1 === dragItemIndex)
+ ) {
+ if (
+ this.state.dragQualityIndex != null &&
+ this.state.dropQualityIndex != null &&
+ this.state.dropPosition != null
+ ) {
+ this.setState({
+ dragQualityIndex: null,
+ dropQualityIndex: null,
+ dropPosition: null
+ });
+ }
+
+ return;
+ }
+
+ let adjustedDropQualityIndex = dropQualityIndex;
+
+ // Correct dragging out of a group to the position above
+ if (
+ dropPosition === 'above' &&
+ dragGroupIndex !== dropGroupIndex &&
+ dropGroupIndex != null
+ ) {
+ // Add 1 to the group index and 2 to the item index so it's inserted above in the correct group
+ adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex + 2}`;
+ }
+
+ // Correct inserting above outside a group
+ if (
+ dropPosition === 'above' &&
+ dragGroupIndex !== dropGroupIndex &&
+ dropGroupIndex == null
+ ) {
+ // Add 2 to the item index so it's entered in the correct place
+ adjustedDropQualityIndex = `${dropItemIndex + 2}`;
+ }
+
+ // Correct inserting below a quality within the same group (when moving a lower item)
+ if (
+ dropPosition === 'below' &&
+ dragGroupIndex === dropGroupIndex &&
+ dropGroupIndex != null &&
+ dragItemIndex < dropItemIndex
+ ) {
+ // Add 1 to the group index leave the item index
+ adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex}`;
+ }
+
+ // Correct inserting below a quality outside a group (when moving a lower item)
+ if (
+ dropPosition === 'below' &&
+ dragGroupIndex === dropGroupIndex &&
+ dropGroupIndex == null &&
+ dragItemIndex < dropItemIndex
+ ) {
+ // Leave the item index so it's inserted below the item
+ adjustedDropQualityIndex = `${dropItemIndex}`;
+ }
+
+ if (
+ dragQualityIndex !== this.state.dragQualityIndex ||
+ adjustedDropQualityIndex !== this.state.dropQualityIndex ||
+ dropPosition !== this.state.dropPosition
+ ) {
+ this.setState({
+ dragQualityIndex,
+ dropQualityIndex: adjustedDropQualityIndex,
+ dropPosition
+ });
+ }
+ };
+
+ onQualityProfileItemDragEnd = (didDrop) => {
+ const {
+ dragQualityIndex,
+ dropQualityIndex
+ } = this.state;
+
+ if (didDrop && dropQualityIndex != null) {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex);
+ const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex);
+
+ let item = null;
+ let dropGroup = null;
+
+ // Get the group before moving anything so we know the correct place to drop it.
+ if (dropGroupIndex != null) {
+ dropGroup = items[dropGroupIndex];
+ }
+
+ if (dragGroupIndex == null) {
+ item = items.splice(dragItemIndex, 1)[0];
+ } else {
+ const group = items[dragGroupIndex];
+ item = group.items.splice(dragItemIndex, 1)[0];
+
+ // If the group is now empty, destroy it.
+ if (!group.items.length) {
+ items.splice(dragGroupIndex, 1);
+ }
+ }
+
+ if (dropGroupIndex == null) {
+ items.splice(dropItemIndex, 0, item);
+ } else {
+ dropGroup.items.splice(dropItemIndex, 0, item);
+ }
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+
+ this.ensureCutoff(qualityProfile);
+ }
+
+ this.setState({
+ dragQualityIndex: null,
+ dropQualityIndex: null,
+ dropPosition: null
+ });
+ };
+
+ onToggleEditGroupsMode = () => {
+ this.setState({ editGroups: !this.state.editGroups });
+ };
+
+ //
+ // Render
+
+ render() {
+ if (_.isEmpty(this.props.item.items) && !this.props.isFetching) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+}
+
+EditQualityProfileModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setQualityProfileValue: PropTypes.func.isRequired,
+ fetchQualityProfileSchema: PropTypes.func.isRequired,
+ saveQualityProfile: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditQualityProfileModalContentConnector);
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.css b/frontend/src/Settings/Profiles/Quality/QualityProfile.css
new file mode 100644
index 000000000..2513577a1
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.css
@@ -0,0 +1,38 @@
+.qualityProfile {
+ composes: card from '~Components/Card.css';
+
+ width: 300px;
+}
+
+.nameContainer {
+ display: flex;
+ justify-content: space-between;
+}
+
+.name {
+ @add-mixin truncate;
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.cloneButton {
+ composes: button from '~Components/Link/IconButton.css';
+
+ height: 36px;
+}
+
+.qualities {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+ pointer-events: all;
+}
+
+.tooltipLabel {
+ composes: label from '~Components/Label.css';
+
+ margin: 0;
+ border: none;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.css.d.ts b/frontend/src/Settings/Profiles/Quality/QualityProfile.css.d.ts
new file mode 100644
index 000000000..9eed097eb
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.css.d.ts
@@ -0,0 +1,12 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'cloneButton': string;
+ 'name': string;
+ 'nameContainer': string;
+ 'qualities': string;
+ 'qualityProfile': string;
+ 'tooltipLabel': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.js b/frontend/src/Settings/Profiles/Quality/QualityProfile.js
new file mode 100644
index 000000000..b28d31b98
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.js
@@ -0,0 +1,187 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import IconButton from 'Components/Link/IconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import Tooltip from 'Components/Tooltip/Tooltip';
+import { icons, kinds, tooltipPositions } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import EditQualityProfileModalConnector from './EditQualityProfileModalConnector';
+import styles from './QualityProfile.css';
+
+class QualityProfile extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditQualityProfileModalOpen: false,
+ isDeleteQualityProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditQualityProfilePress = () => {
+ this.setState({ isEditQualityProfileModalOpen: true });
+ };
+
+ onEditQualityProfileModalClose = () => {
+ this.setState({ isEditQualityProfileModalOpen: false });
+ };
+
+ onDeleteQualityProfilePress = () => {
+ this.setState({
+ isEditQualityProfileModalOpen: false,
+ isDeleteQualityProfileModalOpen: true
+ });
+ };
+
+ onDeleteQualityProfileModalClose = () => {
+ this.setState({ isDeleteQualityProfileModalOpen: false });
+ };
+
+ onConfirmDeleteQualityProfile = () => {
+ this.props.onConfirmDeleteQualityProfile(this.props.id);
+ };
+
+ onCloneQualityProfilePress = () => {
+ const {
+ id,
+ onCloneQualityProfilePress
+ } = this.props;
+
+ onCloneQualityProfilePress(id);
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ upgradeAllowed,
+ cutoff,
+ items,
+ isDeleting
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ items.map((item) => {
+ if (!item.allowed) {
+ return null;
+ }
+
+ if (item.quality) {
+ const isCutoff = upgradeAllowed && item.quality.id === cutoff;
+
+ return (
+
+ {item.quality.name}
+
+ );
+ }
+
+ const isCutoff = upgradeAllowed && item.id === cutoff;
+
+ return (
+
+ {item.name}
+
+ }
+ tooltip={
+
+ {
+ item.items.map((groupItem) => {
+ return (
+
+ {groupItem.quality.name}
+
+ );
+ })
+ }
+
+ }
+ kind={kinds.INVERSE}
+ position={tooltipPositions.TOP}
+ />
+ );
+ })
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+QualityProfile.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ upgradeAllowed: PropTypes.bool.isRequired,
+ cutoff: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDeleting: PropTypes.bool.isRequired,
+ onConfirmDeleteQualityProfile: PropTypes.func.isRequired,
+ onCloneQualityProfilePress: PropTypes.func.isRequired
+};
+
+export default QualityProfile;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.css b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.css
new file mode 100644
index 000000000..4f235ba35
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.css
@@ -0,0 +1,45 @@
+.qualityProfileFormatItemContainer {
+ display: flex;
+ padding: $qualityProfileItemDragSourcePadding 0;
+ width: 100%;
+}
+
+.qualityProfileFormatItem {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+ border: 1px solid #aaa;
+ border-radius: 4px;
+ background: var(--inputBackgroundColor);
+}
+
+.formatNameContainer {
+ display: flex;
+ flex-grow: 1;
+ margin-bottom: 0;
+ margin-left: 14px;
+ width: 100%;
+ font-weight: normal;
+ line-height: $qualityProfileItemHeight;
+ cursor: text;
+}
+
+.formatName {
+ display: flex;
+ flex-grow: 1;
+}
+
+.scoreContainer {
+ display: flex;
+ flex-grow: 0;
+}
+
+.scoreInput {
+ composes: input from '~Components/Form/Input.css';
+
+ width: 100px;
+ height: 30px;
+ border: unset;
+ border-radius: unset;
+ background-color: unset;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.css.d.ts b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.css.d.ts
new file mode 100644
index 000000000..faa350dcd
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.css.d.ts
@@ -0,0 +1,12 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'formatName': string;
+ 'formatNameContainer': string;
+ 'qualityProfileFormatItem': string;
+ 'qualityProfileFormatItemContainer': string;
+ 'scoreContainer': string;
+ 'scoreInput': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.js b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.js
new file mode 100644
index 000000000..5ef4add2d
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItem.js
@@ -0,0 +1,68 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import NumberInput from 'Components/Form/NumberInput';
+import styles from './QualityProfileFormatItem.css';
+
+class QualityProfileFormatItem extends Component {
+
+ //
+ // Listeners
+
+ onScoreChange = ({ value }) => {
+ const {
+ formatId
+ } = this.props;
+
+ this.props.onScoreChange(formatId, value);
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ score
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+QualityProfileFormatItem.propTypes = {
+ formatId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ score: PropTypes.number.isRequired,
+ onScoreChange: PropTypes.func
+};
+
+QualityProfileFormatItem.defaultProps = {
+ // To handle the case score is deleted during edit
+ score: 0
+};
+
+export default QualityProfileFormatItem;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.css b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.css
new file mode 100644
index 000000000..def839960
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.css
@@ -0,0 +1,31 @@
+.formats {
+ margin-top: 10px;
+ /* TODO: This should consider the number of languages in the list */
+ user-select: none;
+}
+
+.headerContainer {
+ display: flex;
+ font-weight: bold;
+ line-height: 35px;
+}
+
+.headerTitle {
+ display: flex;
+ flex-grow: 1;
+}
+
+.headerScore {
+ display: flex;
+ flex-grow: 0;
+ padding-left: 16px;
+ width: 100px;
+}
+
+.addCustomFormatMessage {
+ max-width: $formGroupExtraSmallWidth;
+ color: var(--helpTextColor);
+ text-align: center;
+ font-weight: 300;
+ font-size: 20px;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.css.d.ts b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.css.d.ts
new file mode 100644
index 000000000..75c081099
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.css.d.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'addCustomFormatMessage': string;
+ 'formats': string;
+ 'headerContainer': string;
+ 'headerScore': string;
+ 'headerTitle': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.js b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.js
new file mode 100644
index 000000000..9ec4bb841
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.js
@@ -0,0 +1,160 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputHelpText from 'Components/Form/FormInputHelpText';
+import FormLabel from 'Components/Form/FormLabel';
+import Link from 'Components/Link/Link';
+import { sizes } from 'Helpers/Props';
+import QualityProfileFormatItem from './QualityProfileFormatItem';
+import styles from './QualityProfileFormatItems.css';
+
+function calcOrder(profileFormatItems) {
+ const items = profileFormatItems.reduce((acc, cur, index) => {
+ acc[cur.format] = index;
+ return acc;
+ }, {});
+
+ return [...profileFormatItems].sort((a, b) => {
+ if (b.score !== a.score) {
+ return b.score - a.score;
+ }
+
+ return a.name.localeCompare(b.name, undefined, { numeric: true });
+ }).map((x) => items[x.format]);
+}
+
+class QualityProfileFormatItems extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ order: calcOrder(this.props.profileFormatItems)
+ };
+ }
+
+ //
+ // Listeners
+
+ onScoreChange = (formatId, value) => {
+ const {
+ onQualityProfileFormatItemScoreChange
+ } = this.props;
+
+ onQualityProfileFormatItemScoreChange(formatId, value);
+ this.reorderItems();
+ };
+
+ reorderItems = _.debounce(() => this.setState({ order: calcOrder(this.props.profileFormatItems) }), 1000);
+
+ //
+ // Render
+
+ render() {
+ const {
+ profileFormatItems,
+ errors,
+ warnings
+ } = this.props;
+
+ const {
+ order
+ } = this.state;
+
+ if (profileFormatItems.length < 1) {
+ return (
+
+ {'Want more control over which downloads are preferred? Add a'}
+ Custom Format
+
+ );
+ }
+
+ return (
+
+
+ Custom Formats
+
+
+
+
+
+ {
+ errors.map((error, index) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ warnings.map((warning, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+ Custom Format
+
+
+ Score
+
+
+ {
+ order.map((index) => {
+ const {
+ format,
+ name,
+ score
+ } = profileFormatItems[index];
+ return (
+
+ );
+ })
+ }
+
+
+
+ );
+ }
+}
+
+QualityProfileFormatItems.propTypes = {
+ profileFormatItems: PropTypes.arrayOf(PropTypes.object).isRequired,
+ errors: PropTypes.arrayOf(PropTypes.object),
+ warnings: PropTypes.arrayOf(PropTypes.object),
+ onQualityProfileFormatItemScoreChange: PropTypes.func
+};
+
+QualityProfileFormatItems.defaultProps = {
+ errors: [],
+ warnings: []
+};
+
+export default QualityProfileFormatItems;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css
new file mode 100644
index 000000000..c10bcd200
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css
@@ -0,0 +1,86 @@
+.qualityProfileItem {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+ border: 1px solid #aaa;
+ border-radius: 4px;
+ background: var(--inputBackgroundColor);
+
+ &.isInGroup {
+ border-style: dashed;
+ }
+}
+
+.checkInputContainer {
+ position: relative;
+ margin-right: 4px;
+ margin-bottom: 5px;
+ margin-left: 8px;
+}
+
+.checkInput {
+ composes: input from '~Components/Form/CheckInput.css';
+
+ margin-top: 5px;
+}
+
+.qualityNameContainer {
+ display: flex;
+ flex-grow: 1;
+ margin-bottom: 0;
+ margin-left: 2px;
+ font-weight: normal;
+ line-height: $qualityProfileItemHeight;
+ cursor: pointer;
+}
+
+.qualityName {
+ &.isInGroup {
+ margin-left: 14px;
+ }
+
+ &.notAllowed {
+ color: #c6c6c6;
+ }
+}
+
+.createGroupButton {
+ composes: buton from '~Components/Link/IconButton.css';
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-right: 5px;
+ margin-left: 8px;
+ width: 20px;
+}
+
+.dragHandle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-left: auto;
+ width: $dragHandleWidth;
+ text-align: center;
+ cursor: grab;
+}
+
+.dragIcon {
+ top: 0;
+}
+
+.isDragging {
+ opacity: 0.25;
+}
+
+.isPreview {
+ .qualityName {
+ margin-left: 14px;
+
+ &.isInGroup {
+ margin-left: 28px;
+ }
+ }
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css.d.ts b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css.d.ts
new file mode 100644
index 000000000..2f18ba539
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css.d.ts
@@ -0,0 +1,18 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'checkInput': string;
+ 'checkInputContainer': string;
+ 'createGroupButton': string;
+ 'dragHandle': string;
+ 'dragIcon': string;
+ 'isDragging': string;
+ 'isInGroup': string;
+ 'isPreview': string;
+ 'notAllowed': string;
+ 'qualityName': string;
+ 'qualityNameContainer': string;
+ 'qualityProfileItem': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js
new file mode 100644
index 000000000..5550464cb
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js
@@ -0,0 +1,132 @@
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import CheckInput from 'Components/Form/CheckInput';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import { icons } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import styles from './QualityProfileItem.css';
+
+class QualityProfileItem extends Component {
+
+ //
+ // Listeners
+
+ onAllowedChange = ({ value }) => {
+ const {
+ qualityId,
+ onQualityProfileItemAllowedChange
+ } = this.props;
+
+ onQualityProfileItemAllowedChange(qualityId, value);
+ };
+
+ onCreateGroupPress = () => {
+ const {
+ qualityId,
+ onCreateGroupPress
+ } = this.props;
+
+ onCreateGroupPress(qualityId);
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ editGroups,
+ isPreview,
+ groupId,
+ name,
+ allowed,
+ isDragging,
+ isOverCurrent,
+ connectDragSource
+ } = this.props;
+
+ return (
+
+
+ {
+ editGroups && !groupId && !isPreview &&
+
+ }
+
+ {
+ !editGroups &&
+
+ }
+
+
+ {name}
+
+
+
+ {
+ connectDragSource(
+
+
+
+ )
+ }
+
+ );
+ }
+}
+
+QualityProfileItem.propTypes = {
+ editGroups: PropTypes.bool,
+ isPreview: PropTypes.bool,
+ groupId: PropTypes.number,
+ qualityId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ isDragging: PropTypes.bool.isRequired,
+ isOverCurrent: PropTypes.bool.isRequired,
+ isInGroup: PropTypes.bool,
+ connectDragSource: PropTypes.func,
+ onCreateGroupPress: PropTypes.func,
+ onQualityProfileItemAllowedChange: PropTypes.func
+};
+
+QualityProfileItem.defaultProps = {
+ isPreview: false,
+ isOverCurrent: false,
+ // The drag preview will not connect the drag handle.
+ connectDragSource: (node) => node
+};
+
+export default QualityProfileItem;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css
new file mode 100644
index 000000000..b927d9bce
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css
@@ -0,0 +1,4 @@
+.dragPreview {
+ width: 380px;
+ opacity: 0.75;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css.d.ts b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css.d.ts
new file mode 100644
index 000000000..1f1f3c320
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'dragPreview': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js
new file mode 100644
index 000000000..31290baa9
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js
@@ -0,0 +1,92 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { DragLayer } from 'react-dnd';
+import DragPreviewLayer from 'Components/DragPreviewLayer';
+import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes';
+import dimensions from 'Styles/Variables/dimensions.js';
+import QualityProfileItem from './QualityProfileItem';
+import styles from './QualityProfileItemDragPreview.css';
+
+const formGroupExtraSmallWidth = parseInt(dimensions.formGroupExtraSmallWidth);
+const formLabelSmallWidth = parseInt(dimensions.formLabelSmallWidth);
+const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth);
+const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
+
+function collectDragLayer(monitor) {
+ return {
+ item: monitor.getItem(),
+ itemType: monitor.getItemType(),
+ currentOffset: monitor.getSourceClientOffset()
+ };
+}
+
+class QualityProfileItemDragPreview extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ item,
+ itemType,
+ currentOffset
+ } = this.props;
+
+ if (!currentOffset || itemType !== QUALITY_PROFILE_ITEM) {
+ return null;
+ }
+
+ // The offset is shifted because the drag handle is on the right edge of the
+ // list item and the preview is wider than the drag handle.
+
+ const { x, y } = currentOffset;
+ const handleOffset = formGroupExtraSmallWidth - formLabelSmallWidth - formLabelRightMarginWidth - dragHandleWidth;
+ const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
+
+ const style = {
+ position: 'absolute',
+ WebkitTransform: transform,
+ msTransform: transform,
+ transform
+ };
+
+ const {
+ editGroups,
+ groupId,
+ qualityId,
+ name,
+ allowed
+ } = item;
+
+ // TODO: Show a different preview for groups
+
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+QualityProfileItemDragPreview.propTypes = {
+ item: PropTypes.object,
+ itemType: PropTypes.string,
+ currentOffset: PropTypes.shape({
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired
+ })
+};
+
+export default DragLayer(collectDragLayer)(QualityProfileItemDragPreview);
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css
new file mode 100644
index 000000000..d5061cc95
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css
@@ -0,0 +1,18 @@
+.qualityProfileItemDragSource {
+ padding: $qualityProfileItemDragSourcePadding 0;
+}
+
+.qualityProfileItemPlaceholder {
+ width: 100%;
+ height: $qualityProfileItemHeight;
+ border: 1px dotted #aaa;
+ border-radius: 4px;
+}
+
+.qualityProfileItemPlaceholderBefore {
+ margin-bottom: 8px;
+}
+
+.qualityProfileItemPlaceholderAfter {
+ margin-top: 8px;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css.d.ts b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css.d.ts
new file mode 100644
index 000000000..32d2e00a7
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css.d.ts
@@ -0,0 +1,10 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'qualityProfileItemDragSource': string;
+ 'qualityProfileItemPlaceholder': string;
+ 'qualityProfileItemPlaceholderAfter': string;
+ 'qualityProfileItemPlaceholderBefore': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js
new file mode 100644
index 000000000..b47470171
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js
@@ -0,0 +1,241 @@
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { DragSource, DropTarget } from 'react-dnd';
+import { findDOMNode } from 'react-dom';
+import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes';
+import QualityProfileItem from './QualityProfileItem';
+import QualityProfileItemGroup from './QualityProfileItemGroup';
+import styles from './QualityProfileItemDragSource.css';
+
+const qualityProfileItemDragSource = {
+ beginDrag(props) {
+ const {
+ editGroups,
+ qualityIndex,
+ groupId,
+ qualityId,
+ name,
+ allowed
+ } = props;
+
+ return {
+ editGroups,
+ qualityIndex,
+ groupId,
+ qualityId,
+ isGroup: !qualityId,
+ name,
+ allowed
+ };
+ },
+
+ endDrag(props, monitor, component) {
+ props.onQualityProfileItemDragEnd(monitor.didDrop());
+ }
+};
+
+const qualityProfileItemDropTarget = {
+ hover(props, monitor, component) {
+ const {
+ qualityIndex: dragQualityIndex,
+ isGroup: isDragGroup
+ } = monitor.getItem();
+
+ const dropQualityIndex = props.qualityIndex;
+ const isDropGroupItem = !!(props.qualityId && props.groupId);
+
+ // Use childNodeIndex to select the correct node to get the middle of so
+ // we don't bounce between above and below causing rapid setState calls.
+ const childNodeIndex = component.props.isOverCurrent && component.props.isDraggingUp ? 1 :0;
+ const componentDOMNode = findDOMNode(component).children[childNodeIndex];
+ const hoverBoundingRect = componentDOMNode.getBoundingClientRect();
+ const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
+ const clientOffset = monitor.getClientOffset();
+ const hoverClientY = clientOffset.y - hoverBoundingRect.top;
+
+ // If we're hovering over a child don't trigger on the parent
+ if (!monitor.isOver({ shallow: true })) {
+ return;
+ }
+
+ // Don't show targets for dropping on self
+ if (dragQualityIndex === dropQualityIndex) {
+ return;
+ }
+
+ // Don't allow a group to be dropped inside a group
+ if (isDragGroup && isDropGroupItem) {
+ return;
+ }
+
+ let dropPosition = null;
+
+ // Determine drop position based on position over target
+ if (hoverClientY > hoverMiddleY) {
+ dropPosition = 'below';
+ } else if (hoverClientY < hoverMiddleY) {
+ dropPosition = 'above';
+ } else {
+ return;
+ }
+
+ props.onQualityProfileItemDragMove({
+ dragQualityIndex,
+ dropQualityIndex,
+ dropPosition
+ });
+ }
+};
+
+function collectDragSource(connect, monitor) {
+ return {
+ connectDragSource: connect.dragSource(),
+ isDragging: monitor.isDragging()
+ };
+}
+
+function collectDropTarget(connect, monitor) {
+ return {
+ connectDropTarget: connect.dropTarget(),
+ isOver: monitor.isOver(),
+ isOverCurrent: monitor.isOver({ shallow: true })
+ };
+}
+
+class QualityProfileItemDragSource extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ editGroups,
+ groupId,
+ qualityId,
+ name,
+ allowed,
+ items,
+ qualityIndex,
+ isDragging,
+ isDraggingUp,
+ isDraggingDown,
+ isOverCurrent,
+ connectDragSource,
+ connectDropTarget,
+ onCreateGroupPress,
+ onDeleteGroupPress,
+ onQualityProfileItemAllowedChange,
+ onItemGroupAllowedChange,
+ onItemGroupNameChange,
+ onQualityProfileItemDragMove,
+ onQualityProfileItemDragEnd
+ } = this.props;
+
+ const isBefore = !isDragging && isDraggingUp && isOverCurrent;
+ const isAfter = !isDragging && isDraggingDown && isOverCurrent;
+
+ return connectDropTarget(
+
+ {
+ isBefore &&
+
+ }
+
+ {
+ !!groupId && qualityId == null &&
+
+ }
+
+ {
+ qualityId != null &&
+
+ }
+
+ {
+ isAfter &&
+
+ }
+
+ );
+ }
+}
+
+QualityProfileItemDragSource.propTypes = {
+ editGroups: PropTypes.bool.isRequired,
+ groupId: PropTypes.number,
+ qualityId: PropTypes.number,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object),
+ qualityIndex: PropTypes.string.isRequired,
+ isDragging: PropTypes.bool,
+ isDraggingUp: PropTypes.bool,
+ isDraggingDown: PropTypes.bool,
+ isOverCurrent: PropTypes.bool,
+ isInGroup: PropTypes.bool,
+ connectDragSource: PropTypes.func,
+ connectDropTarget: PropTypes.func,
+ onCreateGroupPress: PropTypes.func,
+ onDeleteGroupPress: PropTypes.func,
+ onQualityProfileItemAllowedChange: PropTypes.func.isRequired,
+ onItemGroupAllowedChange: PropTypes.func,
+ onItemGroupNameChange: PropTypes.func,
+ onQualityProfileItemDragMove: PropTypes.func.isRequired,
+ onQualityProfileItemDragEnd: PropTypes.func.isRequired
+};
+
+export default DropTarget(
+ QUALITY_PROFILE_ITEM,
+ qualityProfileItemDropTarget,
+ collectDropTarget
+)(DragSource(
+ QUALITY_PROFILE_ITEM,
+ qualityProfileItemDragSource,
+ collectDragSource
+)(QualityProfileItemDragSource));
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css
new file mode 100644
index 000000000..772bd9a80
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css
@@ -0,0 +1,106 @@
+.qualityProfileItemGroup {
+ width: 100%;
+ border: 1px solid #aaa;
+ border-radius: 4px;
+ background: var(--inputBackgroundColor);
+
+ &.editGroups {
+ background: var(--inputBackgroundColor);
+ }
+}
+
+.qualityProfileItemGroupInfo {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+}
+
+.checkInputContainer {
+ composes: checkInputContainer from '~./QualityProfileItem.css';
+
+ display: flex;
+ align-items: center;
+}
+
+.checkInput {
+ composes: checkInput from '~./QualityProfileItem.css';
+}
+
+.nameInput {
+ composes: input from '~Components/Form/TextInput.css';
+
+ margin-top: 4px;
+ margin-right: 10px;
+}
+
+.nameContainer {
+ display: flex;
+ align-items: center;
+ flex-grow: 1;
+}
+
+.name {
+ flex-shrink: 0;
+
+ &.notAllowed {
+ color: #c6c6c6;
+ }
+}
+
+.groupQualities {
+ display: flex;
+ justify-content: flex-end;
+ flex-grow: 1;
+ flex-wrap: wrap;
+ margin: 2px 0 2px 10px;
+}
+
+.qualityNameContainer {
+ display: flex;
+ align-items: stretch;
+ flex-grow: 1;
+ margin-bottom: 0;
+ margin-left: 2px;
+ font-weight: normal;
+}
+
+.qualityNameLabel {
+ composes: qualityNameContainer;
+
+ cursor: pointer;
+}
+
+.deleteGroupButton {
+ composes: buton from '~Components/Link/IconButton.css';
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-right: 5px;
+ margin-left: 8px;
+ width: 20px;
+}
+
+.dragHandle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-left: auto;
+ width: $dragHandleWidth;
+ text-align: center;
+ cursor: grab;
+}
+
+.dragIcon {
+ top: 0;
+}
+
+.isDragging {
+ opacity: 0.25;
+}
+
+.items {
+ margin: 0 50px 0 35px;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css.d.ts b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css.d.ts
new file mode 100644
index 000000000..73ffeb3e5
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css.d.ts
@@ -0,0 +1,23 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'checkInput': string;
+ 'checkInputContainer': string;
+ 'deleteGroupButton': string;
+ 'dragHandle': string;
+ 'dragIcon': string;
+ 'editGroups': string;
+ 'groupQualities': string;
+ 'isDragging': string;
+ 'items': string;
+ 'name': string;
+ 'nameContainer': string;
+ 'nameInput': string;
+ 'notAllowed': string;
+ 'qualityNameContainer': string;
+ 'qualityNameLabel': string;
+ 'qualityProfileItemGroup': string;
+ 'qualityProfileItemGroupInfo': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js
new file mode 100644
index 000000000..499b62016
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js
@@ -0,0 +1,201 @@
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import CheckInput from 'Components/Form/CheckInput';
+import TextInput from 'Components/Form/TextInput';
+import Icon from 'Components/Icon';
+import Label from 'Components/Label';
+import IconButton from 'Components/Link/IconButton';
+import { icons } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import QualityProfileItemDragSource from './QualityProfileItemDragSource';
+import styles from './QualityProfileItemGroup.css';
+
+class QualityProfileItemGroup extends Component {
+
+ //
+ // Listeners
+
+ onAllowedChange = ({ value }) => {
+ const {
+ groupId,
+ onItemGroupAllowedChange
+ } = this.props;
+
+ onItemGroupAllowedChange(groupId, value);
+ };
+
+ onNameChange = ({ value }) => {
+ const {
+ groupId,
+ onItemGroupNameChange
+ } = this.props;
+
+ onItemGroupNameChange(groupId, value);
+ };
+
+ onDeleteGroupPress = ({ value }) => {
+ const {
+ groupId,
+ onDeleteGroupPress
+ } = this.props;
+
+ onDeleteGroupPress(groupId, value);
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ editGroups,
+ groupId,
+ name,
+ allowed,
+ items,
+ qualityIndex,
+ isDragging,
+ isDraggingUp,
+ isDraggingDown,
+ connectDragSource,
+ onQualityProfileItemAllowedChange,
+ onQualityProfileItemDragMove,
+ onQualityProfileItemDragEnd
+ } = this.props;
+
+ return (
+
+
+ {
+ editGroups &&
+
+
+
+
+
+ }
+
+ {
+ !editGroups &&
+
+
+
+
+
+ {name}
+
+
+
+ {
+ items.map(({ quality }) => {
+ return (
+
+ {quality.name}
+
+ );
+ }).reverse()
+ }
+
+
+
+ }
+
+ {
+ connectDragSource(
+
+
+
+ )
+ }
+
+
+ {
+ editGroups &&
+
+ {
+ items.map(({ quality }, index) => {
+ return (
+
+ );
+ }).reverse()
+ }
+
+ }
+
+ );
+ }
+}
+
+QualityProfileItemGroup.propTypes = {
+ editGroups: PropTypes.bool,
+ groupId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ qualityIndex: PropTypes.string.isRequired,
+ isDragging: PropTypes.bool.isRequired,
+ isDraggingUp: PropTypes.bool.isRequired,
+ isDraggingDown: PropTypes.bool.isRequired,
+ connectDragSource: PropTypes.func,
+ onItemGroupAllowedChange: PropTypes.func.isRequired,
+ onQualityProfileItemAllowedChange: PropTypes.func.isRequired,
+ onItemGroupNameChange: PropTypes.func.isRequired,
+ onDeleteGroupPress: PropTypes.func.isRequired,
+ onQualityProfileItemDragMove: PropTypes.func.isRequired,
+ onQualityProfileItemDragEnd: PropTypes.func.isRequired
+};
+
+QualityProfileItemGroup.defaultProps = {
+ // The drag preview will not connect the drag handle.
+ connectDragSource: (node) => node
+};
+
+export default QualityProfileItemGroup;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css
new file mode 100644
index 000000000..002e555a7
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css
@@ -0,0 +1,15 @@
+.editGroupsButton {
+ composes: button from '~Components/Link/Button.css';
+
+ margin-top: 10px;
+}
+
+.editGroupsButtonIcon {
+ margin-right: 8px;
+}
+
+.qualities {
+ margin-top: 10px;
+ transition: min-height 200ms;
+ user-select: none;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css.d.ts b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css.d.ts
new file mode 100644
index 000000000..159ce7759
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'editGroupsButton': string;
+ 'editGroupsButtonIcon': string;
+ 'qualities': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js
new file mode 100644
index 000000000..87d112545
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js
@@ -0,0 +1,182 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputHelpText from 'Components/Form/FormInputHelpText';
+import FormLabel from 'Components/Form/FormLabel';
+import Icon from 'Components/Icon';
+import Button from 'Components/Link/Button';
+import Measure from 'Components/Measure';
+import { icons, kinds, sizes } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import QualityProfileItemDragPreview from './QualityProfileItemDragPreview';
+import QualityProfileItemDragSource from './QualityProfileItemDragSource';
+import styles from './QualityProfileItems.css';
+
+class QualityProfileItems extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ qualitiesHeight: 0,
+ qualitiesHeightEditGroups: 0
+ };
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ height }) => {
+ if (this.props.editGroups) {
+ this.setState({
+ qualitiesHeightEditGroups: height
+ });
+ } else {
+ this.setState({ qualitiesHeight: height });
+ }
+ };
+
+ onToggleEditGroupsMode = () => {
+ this.props.onToggleEditGroupsMode();
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ editGroups,
+ dropQualityIndex,
+ dropPosition,
+ qualityProfileItems,
+ errors,
+ warnings,
+ ...otherProps
+ } = this.props;
+
+ const {
+ qualitiesHeight,
+ qualitiesHeightEditGroups
+ } = this.state;
+
+ const isDragging = dropQualityIndex !== null;
+ const isDraggingUp = isDragging && dropPosition === 'above';
+ const isDraggingDown = isDragging && dropPosition === 'below';
+ const minHeight = editGroups ? qualitiesHeightEditGroups : qualitiesHeight;
+
+ return (
+
+
+ Qualities
+
+
+
+
+
+ {
+ errors.map((error, index) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ warnings.map((warning, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ {
+ editGroups ? translate('DoneEditingGroups') : translate('EditGroups')
+ }
+
+
+
+
+
+ {
+ qualityProfileItems.map(({ id, name, allowed, quality, items }, index) => {
+ const identifier = quality ? quality.id : id;
+
+ return (
+
+ );
+ }).reverse()
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+QualityProfileItems.propTypes = {
+ editGroups: PropTypes.bool.isRequired,
+ dragQualityIndex: PropTypes.string,
+ dropQualityIndex: PropTypes.string,
+ dropPosition: PropTypes.string,
+ qualityProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired,
+ errors: PropTypes.arrayOf(PropTypes.object),
+ warnings: PropTypes.arrayOf(PropTypes.object),
+ onToggleEditGroupsMode: PropTypes.func.isRequired
+};
+
+QualityProfileItems.defaultProps = {
+ errors: [],
+ warnings: []
+};
+
+export default QualityProfileItems;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js b/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js
new file mode 100644
index 000000000..bf13815ff
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector';
+
+function createMapStateToProps() {
+ return createSelector(
+ createQualityProfileSelector(),
+ (qualityProfile) => {
+ return {
+ name: qualityProfile.name
+ };
+ }
+ );
+}
+
+function QualityProfileNameConnector({ name, ...otherProps }) {
+ return (
+
+ {name}
+
+ );
+}
+
+QualityProfileNameConnector.propTypes = {
+ qualityProfileId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired
+};
+
+export default connect(createMapStateToProps)(QualityProfileNameConnector);
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfiles.css b/frontend/src/Settings/Profiles/Quality/QualityProfiles.css
new file mode 100644
index 000000000..32dad3fac
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfiles.css
@@ -0,0 +1,21 @@
+.qualityProfiles {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addQualityProfile {
+ composes: qualityProfile from '~./QualityProfile.css';
+
+ background-color: var(--cardAlternateBackgroundColor);
+ color: var(--gray);
+ text-align: center;
+ font-size: 45px;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid var(--borderColor);
+ border-radius: 4px;
+ background-color: var(--cardCenterBackgroundColor);
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfiles.css.d.ts b/frontend/src/Settings/Profiles/Quality/QualityProfiles.css.d.ts
new file mode 100644
index 000000000..f3033cab2
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfiles.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'addQualityProfile': string;
+ 'center': string;
+ 'qualityProfiles': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfiles.js b/frontend/src/Settings/Profiles/Quality/QualityProfiles.js
new file mode 100644
index 000000000..c2405447b
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfiles.js
@@ -0,0 +1,106 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Card from 'Components/Card';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import { icons } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import EditQualityProfileModalConnector from './EditQualityProfileModalConnector';
+import QualityProfile from './QualityProfile';
+import styles from './QualityProfiles.css';
+
+class QualityProfiles extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isQualityProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onCloneQualityProfilePress = (id) => {
+ this.props.onCloneQualityProfilePress(id);
+ this.setState({ isQualityProfileModalOpen: true });
+ };
+
+ onEditQualityProfilePress = () => {
+ this.setState({ isQualityProfileModalOpen: true });
+ };
+
+ onModalClose = () => {
+ this.setState({ isQualityProfileModalOpen: false });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ isDeleting,
+ onConfirmDeleteQualityProfile,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+QualityProfiles.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isDeleting: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteQualityProfile: PropTypes.func.isRequired,
+ onCloneQualityProfilePress: PropTypes.func.isRequired
+};
+
+export default QualityProfiles;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js
new file mode 100644
index 000000000..4cb318463
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js
@@ -0,0 +1,63 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { cloneQualityProfile, deleteQualityProfile, fetchQualityProfiles } from 'Store/Actions/settingsActions';
+import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
+import sortByProp from 'Utilities/Array/sortByProp';
+import QualityProfiles from './QualityProfiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ createSortedSectionSelector('settings.qualityProfiles', sortByProp('name')),
+ (qualityProfiles) => qualityProfiles
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchQualityProfiles: fetchQualityProfiles,
+ dispatchDeleteQualityProfile: deleteQualityProfile,
+ dispatchCloneQualityProfile: cloneQualityProfile
+};
+
+class QualityProfilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchQualityProfiles();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteQualityProfile = (id) => {
+ this.props.dispatchDeleteQualityProfile({ id });
+ };
+
+ onCloneQualityProfilePress = (id) => {
+ this.props.dispatchCloneQualityProfile({ id });
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QualityProfilesConnector.propTypes = {
+ dispatchFetchQualityProfiles: PropTypes.func.isRequired,
+ dispatchDeleteQualityProfile: PropTypes.func.isRequired,
+ dispatchCloneQualityProfile: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(QualityProfilesConnector);
diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModal.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModal.js
new file mode 100644
index 000000000..a948ab123
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import { sizes } from 'Helpers/Props';
+import EditReleaseProfileModalContentConnector from './EditReleaseProfileModalContentConnector';
+
+function EditReleaseProfileModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditReleaseProfileModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditReleaseProfileModal;
diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js
new file mode 100644
index 000000000..e846ff6ff
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditReleaseProfileModal from './EditReleaseProfileModal';
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditReleaseProfileModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'settings.releaseProfiles' });
+ this.props.onModalClose();
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditReleaseProfileModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(null, mapDispatchToProps)(EditReleaseProfileModalConnector);
diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css
new file mode 100644
index 000000000..a2b6014df
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css
@@ -0,0 +1,5 @@
+.deleteButton {
+ composes: button from '~Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css.d.ts b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css.d.ts
new file mode 100644
index 000000000..c5f0ef8a7
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'deleteButton': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js
new file mode 100644
index 000000000..e1c695c42
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js
@@ -0,0 +1,169 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import { inputTypes, kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import styles from './EditReleaseProfileModalContent.css';
+
+const tagInputDelimiters = ['Tab', 'Enter'];
+
+function EditReleaseProfileModalContent(props) {
+ const {
+ isSaving,
+ saveError,
+ item,
+ onInputChange,
+ onModalClose,
+ onSavePress,
+ onDeleteReleaseProfilePress,
+ ...otherProps
+ } = props;
+
+ const {
+ id,
+ enabled,
+ required,
+ ignored,
+ tags,
+ indexerId
+ } = item;
+
+ return (
+
+
+ {id ? translate('EditReleaseProfile') : translate('AddReleaseProfile')}
+
+
+
+
+
+
+ {
+ id &&
+
+ {translate('Delete')}
+
+ }
+
+
+ {translate('Cancel')}
+
+
+
+ {translate('Save')}
+
+
+
+ );
+}
+
+EditReleaseProfileModalContent.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onDeleteReleaseProfilePress: PropTypes.func
+};
+
+export default EditReleaseProfileModalContent;
diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js
new file mode 100644
index 000000000..b14d72269
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js
@@ -0,0 +1,114 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { saveReleaseProfile, setReleaseProfileValue } from 'Store/Actions/settingsActions';
+import selectSettings from 'Store/Selectors/selectSettings';
+import EditReleaseProfileModalContent from './EditReleaseProfileModalContent';
+
+const newReleaseProfile = {
+ enabled: true,
+ required: [],
+ ignored: [],
+ includePreferredWhenRenaming: false,
+ tags: [],
+ indexerId: 0
+};
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings.releaseProfiles,
+ (id, releaseProfiles) => {
+ const {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ pendingChanges,
+ items
+ } = releaseProfiles;
+
+ const profile = id ? _.find(items, { id }) : newReleaseProfile;
+ const settings = selectSettings(profile, pendingChanges, saveError);
+
+ return {
+ id,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ item: settings.settings,
+ ...settings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setReleaseProfileValue,
+ saveReleaseProfile
+};
+
+class EditReleaseProfileModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.id) {
+ Object.keys(newReleaseProfile).forEach((name) => {
+ this.props.setReleaseProfileValue({
+ name,
+ value: newReleaseProfile[name]
+ });
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setReleaseProfileValue({ name, value });
+ };
+
+ onSavePress = () => {
+ this.props.saveReleaseProfile({ id: this.props.id });
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditReleaseProfileModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setReleaseProfileValue: PropTypes.func.isRequired,
+ saveReleaseProfile: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditReleaseProfileModalContentConnector);
diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfile.css b/frontend/src/Settings/Profiles/Release/ReleaseProfile.css
new file mode 100644
index 000000000..09ba2b0c7
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/ReleaseProfile.css
@@ -0,0 +1,17 @@
+.releaseProfile {
+ composes: card from '~Components/Card.css';
+
+ width: 290px;
+}
+
+.enabled {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+}
+
+.label {
+ composes: label from '~Components/Label.css';
+
+ max-width: 100%;
+}
diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfile.css.d.ts b/frontend/src/Settings/Profiles/Release/ReleaseProfile.css.d.ts
new file mode 100644
index 000000000..4245884cc
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/ReleaseProfile.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'enabled': string;
+ 'label': string;
+ 'releaseProfile': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfile.js b/frontend/src/Settings/Profiles/Release/ReleaseProfile.js
new file mode 100644
index 000000000..7883158d7
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/ReleaseProfile.js
@@ -0,0 +1,197 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import MiddleTruncate from 'react-middle-truncate';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import TagList from 'Components/TagList';
+import { kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector';
+import styles from './ReleaseProfile.css';
+
+class ReleaseProfile extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditReleaseProfileModalOpen: false,
+ isDeleteReleaseProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditReleaseProfilePress = () => {
+ this.setState({ isEditReleaseProfileModalOpen: true });
+ };
+
+ onEditReleaseProfileModalClose = () => {
+ this.setState({ isEditReleaseProfileModalOpen: false });
+ };
+
+ onDeleteReleaseProfilePress = () => {
+ this.setState({
+ isEditReleaseProfileModalOpen: false,
+ isDeleteReleaseProfileModalOpen: true
+ });
+ };
+
+ onDeleteReleaseProfileModalClose= () => {
+ this.setState({ isDeleteReleaseProfileModalOpen: false });
+ };
+
+ onConfirmDeleteReleaseProfile = () => {
+ this.props.onConfirmDeleteReleaseProfile(this.props.id);
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ enabled,
+ required,
+ ignored,
+ tags,
+ indexerId,
+ tagList,
+ indexerList
+ } = this.props;
+
+ const {
+ isEditReleaseProfileModalOpen,
+ isDeleteReleaseProfileModalOpen
+ } = this.state;
+
+ const indexer = indexerId !== 0 && _.find(indexerList, { id: indexerId });
+
+ return (
+
+
+ {
+ required.map((item) => {
+ if (!item) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+ })
+ }
+
+
+
+ {
+ ignored.map((item) => {
+ if (!item) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+ })
+ }
+
+
+
+
+
+ {
+ !enabled &&
+
+ Disabled
+
+ }
+
+ {
+ indexer &&
+
+ {indexer.name}
+
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+ReleaseProfile.propTypes = {
+ id: PropTypes.number.isRequired,
+ enabled: PropTypes.bool.isRequired,
+ required: PropTypes.arrayOf(PropTypes.string).isRequired,
+ ignored: PropTypes.arrayOf(PropTypes.string).isRequired,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ indexerId: PropTypes.number.isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ indexerList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteReleaseProfile: PropTypes.func.isRequired
+};
+
+ReleaseProfile.defaultProps = {
+ enabled: true,
+ required: [],
+ ignored: [],
+ indexerId: 0
+};
+
+export default ReleaseProfile;
diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css
new file mode 100644
index 000000000..9e9715e77
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css
@@ -0,0 +1,20 @@
+.releaseProfiles {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addReleaseProfile {
+ composes: releaseProfile from '~./ReleaseProfile.css';
+
+ background-color: var(--cardAlternateBackgroundColor);
+ color: var(--gray);
+ text-align: center;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid var(--borderColor);
+ border-radius: 4px;
+ background-color: var(--cardCenterBackgroundColor);
+}
diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css.d.ts b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css.d.ts
new file mode 100644
index 000000000..be1ba4596
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'addReleaseProfile': string;
+ 'center': string;
+ 'releaseProfiles': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js
new file mode 100644
index 000000000..b04e24eae
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js
@@ -0,0 +1,102 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Card from 'Components/Card';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import { icons } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector';
+import ReleaseProfile from './ReleaseProfile';
+import styles from './ReleaseProfiles.css';
+
+class ReleaseProfiles extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddReleaseProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddReleaseProfilePress = () => {
+ this.setState({ isAddReleaseProfileModalOpen: true });
+ };
+
+ onAddReleaseProfileModalClose = () => {
+ this.setState({ isAddReleaseProfileModalOpen: false });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ tagList,
+ indexerList,
+ onConfirmDeleteReleaseProfile,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ );
+ }
+}
+
+ReleaseProfiles.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ indexerList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteReleaseProfile: PropTypes.func.isRequired
+};
+
+export default ReleaseProfiles;
diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js b/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js
new file mode 100644
index 000000000..0c0d81c77
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js
@@ -0,0 +1,70 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { deleteReleaseProfile, fetchIndexers, fetchReleaseProfiles } from 'Store/Actions/settingsActions';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import ReleaseProfiles from './ReleaseProfiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.releaseProfiles,
+ (state) => state.settings.indexers,
+ createTagsSelector(),
+ (releaseProfiles, indexers, tagList) => {
+ return {
+ ...releaseProfiles,
+ tagList,
+ isIndexersPopulated: indexers.isPopulated,
+ indexerList: indexers.items
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchIndexers,
+ fetchReleaseProfiles,
+ deleteReleaseProfile
+};
+
+class ReleaseProfilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchReleaseProfiles();
+ if (!this.props.isIndexersPopulated) {
+ this.props.fetchIndexers();
+ }
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteReleaseProfile = (id) => {
+ this.props.deleteReleaseProfile({ id });
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ReleaseProfilesConnector.propTypes = {
+ isIndexersPopulated: PropTypes.bool.isRequired,
+ fetchReleaseProfiles: PropTypes.func.isRequired,
+ deleteReleaseProfile: PropTypes.func.isRequired,
+ fetchIndexers: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ReleaseProfilesConnector);
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.css b/frontend/src/Settings/Quality/Definition/QualityDefinition.css
new file mode 100644
index 000000000..860333725
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.css
@@ -0,0 +1,93 @@
+.qualityDefinition {
+ display: flex;
+ align-content: stretch;
+ margin: 5px 0;
+ padding-top: 5px;
+ height: 45px;
+ border-top: 1px solid var(--borderColor);
+}
+
+.quality,
+.title {
+ flex: 0 1 250px;
+ padding-right: 20px;
+ line-height: 40px;
+}
+
+.sizeLimit {
+ flex: 0 1 500px;
+ padding-right: 30px;
+}
+
+.slider {
+ width: 100%;
+ height: 20px;
+}
+
+.track {
+ top: 9px;
+ margin: 0 5px;
+ height: 3px;
+ background-color: var(--sliderAccentColor);
+ box-shadow: 0 0 0 #000;
+
+ &:nth-child(3n + 1) {
+ background-color: #ddd;
+ }
+}
+
+.thumb {
+ top: 1px;
+ z-index: 0 !important;
+ width: 18px;
+ height: 18px;
+ border: 3px solid var(--sliderAccentColor);
+ border-radius: 50%;
+ background-color: var(--white);
+ text-align: center;
+ cursor: pointer;
+}
+
+.sizes {
+ display: flex;
+ justify-content: space-between;
+}
+
+.kilobitsPerSecond {
+ display: flex;
+ justify-content: space-between;
+ flex: 0 0 400px;
+}
+
+.sizeInput {
+ composes: input from '~Components/Form/TextInput.css';
+
+ display: inline-block;
+ margin-left: 5px;
+ padding: 6px;
+ width: 75px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .qualityDefinition {
+ flex-wrap: wrap;
+ height: auto;
+
+ &:first-child {
+ border-top: none;
+ }
+ }
+
+ .qualityDefinition:first-child {
+ border-top: none;
+ }
+
+ .quality {
+ font-weight: bold;
+ line-height: inherit;
+ }
+
+ .sizeLimit {
+ margin-top: 10px;
+ }
+}
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.css.d.ts b/frontend/src/Settings/Quality/Definition/QualityDefinition.css.d.ts
new file mode 100644
index 000000000..9c9e8393a
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.css.d.ts
@@ -0,0 +1,16 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'kilobitsPerSecond': string;
+ 'quality': string;
+ 'qualityDefinition': string;
+ 'sizeInput': string;
+ 'sizeLimit': string;
+ 'sizes': string;
+ 'slider': string;
+ 'thumb': string;
+ 'title': string;
+ 'track': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.js b/frontend/src/Settings/Quality/Definition/QualityDefinition.js
new file mode 100644
index 000000000..48251abfb
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.js
@@ -0,0 +1,331 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactSlider from 'react-slider';
+import NumberInput from 'Components/Form/NumberInput';
+import TextInput from 'Components/Form/TextInput';
+import Label from 'Components/Label';
+import Popover from 'Components/Tooltip/Popover';
+import { kinds, tooltipPositions } from 'Helpers/Props';
+import formatBytes from 'Utilities/Number/formatBytes';
+import roundNumber from 'Utilities/Number/roundNumber';
+import translate from 'Utilities/String/translate';
+import QualityDefinitionLimits from './QualityDefinitionLimits';
+import styles from './QualityDefinition.css';
+
+const MIN = 0;
+const MAX = 1500;
+const MIN_DISTANCE = 1;
+
+const slider = {
+ min: MIN,
+ max: roundNumber(Math.pow(MAX, 1 / 1.1)),
+ step: 0.1
+};
+
+function getValue(inputValue) {
+ if (inputValue < MIN) {
+ return MIN;
+ }
+
+ if (inputValue > MAX) {
+ return MAX;
+ }
+
+ return roundNumber(inputValue);
+}
+
+function getSliderValue(value, defaultValue) {
+ const sliderValue = value ? Math.pow(value, 1 / 1.1) : defaultValue;
+
+ return roundNumber(sliderValue);
+}
+
+class QualityDefinition extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ sliderMinSize: getSliderValue(props.minSize, slider.min),
+ sliderMaxSize: getSliderValue(props.maxSize, slider.max),
+ sliderPreferredSize: getSliderValue(props.preferredSize, (slider.max - 3))
+ };
+ }
+
+ //
+ // Control
+
+ trackRenderer(props, state) {
+ return (
+
+ );
+ }
+
+ thumbRenderer(props, state) {
+ return (
+
+ );
+ }
+
+ //
+ // Listeners
+
+ onSliderChange = ([sliderMinSize, sliderPreferredSize, sliderMaxSize]) => {
+ this.setState({
+ sliderMinSize,
+ sliderMaxSize,
+ sliderPreferredSize
+ });
+
+ this.props.onSizeChange({
+ minSize: roundNumber(Math.pow(sliderMinSize, 1.1)),
+ preferredSize: sliderPreferredSize === (slider.max - 3) ? null : roundNumber(Math.pow(sliderPreferredSize, 1.1)),
+ maxSize: sliderMaxSize === slider.max ? null : roundNumber(Math.pow(sliderMaxSize, 1.1))
+ });
+ };
+
+ onAfterSliderChange = () => {
+ const {
+ minSize,
+ maxSize,
+ preferredSize
+ } = this.props;
+
+ this.setState({
+ sliderMiSize: getSliderValue(minSize, slider.min),
+ sliderMaxSize: getSliderValue(maxSize, slider.max),
+ sliderPreferredSize: getSliderValue(preferredSize, (slider.max - 3)) // fix
+ });
+ };
+
+ onMinSizeChange = ({ value }) => {
+ const minSize = getValue(value);
+
+ this.setState({
+ sliderMinSize: getSliderValue(minSize, slider.min)
+ });
+
+ this.props.onSizeChange({
+ minSize,
+ maxSize: this.props.maxSize,
+ preferredSize: this.props.preferredSize
+ });
+ };
+
+ onPreferredSizeChange = ({ value }) => {
+ const preferredSize = value === (MAX - 3) ? null : getValue(value);
+
+ this.setState({
+ sliderPreferredSize: getSliderValue(preferredSize, slider.preferred)
+ });
+
+ this.props.onSizeChange({
+ minSize: this.props.minSize,
+ maxSize: this.props.maxSize,
+ preferredSize
+ });
+ };
+
+ onMaxSizeChange = ({ value }) => {
+ const maxSize = value === MAX ? null : getValue(value);
+
+ this.setState({
+ sliderMaxSize: getSliderValue(maxSize, slider.max)
+ });
+
+ this.props.onSizeChange({
+ minSize: this.props.minSize,
+ maxSize,
+ preferredSize: this.props.preferredSize
+ });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ quality,
+ title,
+ minSize,
+ maxSize,
+ preferredSize,
+ advancedSettings,
+ onTitleChange
+ } = this.props;
+
+ const {
+ sliderMinSize,
+ sliderMaxSize,
+ sliderPreferredSize
+ } = this.state;
+
+ const minBytes = minSize * 128;
+ const minRate = `${formatBytes(minBytes, true)}/s`;
+
+ const preferredBytes = preferredSize * 128;
+ const preferredRate = preferredBytes ? `${formatBytes(preferredBytes, true)}/s` : translate('Unlimited');
+
+ const maxBytes = maxSize && maxSize * 128;
+ const maxRate = maxBytes ? `${formatBytes(maxBytes, true)}/s` : translate('Unlimited');
+
+ return (
+
+
+ {quality.name}
+
+
+
+
+
+
+
+
+
+
+
+
{minRate}
+ }
+ title={translate('MinimumLimits')}
+ body={
+
+ }
+ position={tooltipPositions.BOTTOM}
+ />
+
+
+
+
{preferredRate}
+ }
+ title={translate('PreferredSize')}
+ body={
+
+ }
+ position={tooltipPositions.BOTTOM}
+ />
+
+
+
+
{maxRate}
+ }
+ title={translate('MaximumLimits')}
+ body={
+
+ }
+ position={tooltipPositions.BOTTOM}
+ />
+
+
+
+
+ {
+ advancedSettings &&
+
+
+ {translate('Min')}
+
+
+
+
+
+ {translate('Preferred')}
+
+
+
+
+
+ {translate('Max')}
+
+
+
+
+ }
+
+ );
+ }
+}
+
+QualityDefinition.propTypes = {
+ id: PropTypes.number.isRequired,
+ quality: PropTypes.object.isRequired,
+ title: PropTypes.string.isRequired,
+ minSize: PropTypes.number,
+ maxSize: PropTypes.number,
+ preferredSize: PropTypes.number,
+ advancedSettings: PropTypes.bool.isRequired,
+ onTitleChange: PropTypes.func.isRequired,
+ onSizeChange: PropTypes.func.isRequired
+};
+
+export default QualityDefinition;
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js b/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js
new file mode 100644
index 000000000..eee0558f1
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js
@@ -0,0 +1,70 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import { setQualityDefinitionValue } from 'Store/Actions/settingsActions';
+import QualityDefinition from './QualityDefinition';
+
+const mapDispatchToProps = {
+ setQualityDefinitionValue,
+ clearPendingChanges
+};
+
+class QualityDefinitionConnector extends Component {
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: 'settings.qualityDefinitions' });
+ }
+
+ //
+ // Listeners
+
+ onTitleChange = ({ value }) => {
+ this.props.setQualityDefinitionValue({ id: this.props.id, name: 'title', value });
+ };
+
+ onSizeChange = ({ minSize, maxSize, preferredSize }) => {
+ const {
+ id,
+ minSize: currentMinSize,
+ maxSize: currentMaxSize,
+ preferredSize: currentPreferredSize
+ } = this.props;
+
+ if (minSize !== currentMinSize) {
+ this.props.setQualityDefinitionValue({ id, name: 'minSize', value: minSize });
+ }
+
+ if (maxSize !== currentMaxSize) {
+ this.props.setQualityDefinitionValue({ id, name: 'maxSize', value: maxSize });
+ }
+
+ if (preferredSize !== currentPreferredSize) {
+ this.props.setQualityDefinitionValue({ id, name: 'preferredSize', value: preferredSize });
+ }
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QualityDefinitionConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ minSize: PropTypes.number,
+ maxSize: PropTypes.number,
+ preferredSize: PropTypes.number,
+ setQualityDefinitionValue: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(null, mapDispatchToProps)(QualityDefinitionConnector);
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitionLimits.js b/frontend/src/Settings/Quality/Definition/QualityDefinitionLimits.js
new file mode 100644
index 000000000..b124ca70c
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionLimits.js
@@ -0,0 +1,40 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import translate from 'Utilities/String/translate';
+
+function QualityDefinitionLimits(props) {
+ const {
+ bytes,
+ message
+ } = props;
+
+ if (!bytes) {
+ return {message}
;
+ }
+
+ const twenty = formatBytes(bytes * 20 * 60);
+ const fourtyFive = formatBytes(bytes * 45 * 60);
+ const sixty = formatBytes(bytes * 60 * 60);
+
+ return (
+
+
+ {translate('20MinutesTwenty', [twenty])}
+
+
+ {translate('45MinutesFourtyFive', [fourtyFive])}
+
+
+ {translate('60MinutesSixty', [sixty])}
+
+
+ );
+}
+
+QualityDefinitionLimits.propTypes = {
+ bytes: PropTypes.number,
+ message: PropTypes.string.isRequired
+};
+
+export default QualityDefinitionLimits;
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitions.css b/frontend/src/Settings/Quality/Definition/QualityDefinitions.css
new file mode 100644
index 000000000..3da40c624
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinitions.css
@@ -0,0 +1,41 @@
+.header {
+ display: flex;
+ font-weight: bold;
+}
+
+.quality,
+.title {
+ flex: 0 1 250px;
+}
+
+.sizeLimit {
+ flex: 0 1 500px;
+}
+
+.kilobitsPerSecond {
+ flex: 0 0 250px;
+}
+
+.sizeLimitHelpTextContainer {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 20px;
+ max-width: 1000px;
+}
+
+.sizeLimitHelpText {
+ max-width: 500px;
+ color: var(--helpTextColor);
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .header {
+ display: none;
+ }
+
+ .definitions {
+ &:first-child {
+ border-top: none;
+ }
+ }
+}
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitions.css.d.ts b/frontend/src/Settings/Quality/Definition/QualityDefinitions.css.d.ts
new file mode 100644
index 000000000..2284fb200
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinitions.css.d.ts
@@ -0,0 +1,14 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'definitions': string;
+ 'header': string;
+ 'kilobitsPerSecond': string;
+ 'quality': string;
+ 'sizeLimit': string;
+ 'sizeLimitHelpText': string;
+ 'sizeLimitHelpTextContainer': string;
+ 'title': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitions.js b/frontend/src/Settings/Quality/Definition/QualityDefinitions.js
new file mode 100644
index 000000000..392b9e1a7
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinitions.js
@@ -0,0 +1,80 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import FieldSet from 'Components/FieldSet';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import translate from 'Utilities/String/translate';
+import QualityDefinitionConnector from './QualityDefinitionConnector';
+import styles from './QualityDefinitions.css';
+
+class QualityDefinitions extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ advancedSettings,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+ {translate('Quality')}
+
+
+ {translate('Title')}
+
+
+ {translate('SizeLimit')}
+
+
+ {
+ advancedSettings ?
+
+ Kilobits Per Second
+
:
+ null
+ }
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+ {translate('QualityLimitsHelpText')}
+
+
+
+
+ );
+ }
+}
+
+QualityDefinitions.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ defaultProfile: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ advancedSettings: PropTypes.bool.isRequired
+};
+
+export default QualityDefinitions;
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js b/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js
new file mode 100644
index 000000000..4b1fc72a6
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js
@@ -0,0 +1,90 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchQualityDefinitions, saveQualityDefinitions } from 'Store/Actions/settingsActions';
+import QualityDefinitions from './QualityDefinitions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.qualityDefinitions,
+ (state) => state.settings.advancedSettings,
+ (qualityDefinitions, advancedSettings) => {
+ const items = qualityDefinitions.items.map((item) => {
+ const pendingChanges = qualityDefinitions.pendingChanges[item.id] || {};
+
+ return Object.assign({}, item, pendingChanges);
+ });
+
+ return {
+ ...qualityDefinitions,
+ items,
+ hasPendingChanges: !_.isEmpty(qualityDefinitions.pendingChanges),
+ advancedSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchQualityDefinitions: fetchQualityDefinitions,
+ dispatchSaveQualityDefinitions: saveQualityDefinitions
+};
+
+class QualityDefinitionsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ dispatchFetchQualityDefinitions,
+ dispatchSaveQualityDefinitions,
+ onChildMounted
+ } = this.props;
+
+ dispatchFetchQualityDefinitions();
+ onChildMounted(dispatchSaveQualityDefinitions);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ hasPendingChanges,
+ isSaving,
+ onChildStateChange
+ } = this.props;
+
+ if (
+ prevProps.isSaving !== isSaving ||
+ prevProps.hasPendingChanges !== hasPendingChanges
+ ) {
+ onChildStateChange({
+ isSaving,
+ hasPendingChanges
+ });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QualityDefinitionsConnector.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ hasPendingChanges: PropTypes.bool.isRequired,
+ dispatchFetchQualityDefinitions: PropTypes.func.isRequired,
+ dispatchSaveQualityDefinitions: PropTypes.func.isRequired,
+ onChildMounted: PropTypes.func.isRequired,
+ onChildStateChange: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps, null)(QualityDefinitionsConnector);
diff --git a/frontend/src/Settings/Quality/Quality.js b/frontend/src/Settings/Quality/Quality.js
new file mode 100644
index 000000000..49c9df5d0
--- /dev/null
+++ b/frontend/src/Settings/Quality/Quality.js
@@ -0,0 +1,105 @@
+import PropTypes from 'prop-types';
+import React, { Component, Fragment } from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import { icons } from 'Helpers/Props';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import translate from 'Utilities/String/translate';
+import QualityDefinitionsConnector from './Definition/QualityDefinitionsConnector';
+import ResetQualityDefinitionsModal from './Reset/ResetQualityDefinitionsModal';
+
+class Quality extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._saveCallback = null;
+
+ this.state = {
+ isSaving: false,
+ hasPendingChanges: false,
+ isConfirmQualityDefinitionResetModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onChildMounted = (saveCallback) => {
+ this._saveCallback = saveCallback;
+ };
+
+ onChildStateChange = (payload) => {
+ this.setState(payload);
+ };
+
+ onResetQualityDefinitionsPress = () => {
+ this.setState({ isConfirmQualityDefinitionResetModalOpen: true });
+ };
+
+ onCloseResetQualityDefinitionsModal = () => {
+ this.setState({ isConfirmQualityDefinitionResetModalOpen: false });
+ };
+
+ onSavePress = () => {
+ if (this._saveCallback) {
+ this._saveCallback();
+ }
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ isSaving,
+ isResettingQualityDefinitions,
+ hasPendingChanges
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+ }
+ onSavePress={this.onSavePress}
+ />
+
+
+
+
+
+
+
+ );
+ }
+}
+
+Quality.propTypes = {
+ isResettingQualityDefinitions: PropTypes.bool.isRequired
+};
+
+export default Quality;
diff --git a/frontend/src/Settings/Quality/QualityConnector.js b/frontend/src/Settings/Quality/QualityConnector.js
new file mode 100644
index 000000000..8cc9219cb
--- /dev/null
+++ b/frontend/src/Settings/Quality/QualityConnector.js
@@ -0,0 +1,38 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import * as commandNames from 'Commands/commandNames';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import Quality from './Quality';
+
+function createMapStateToProps() {
+ return createSelector(
+ createCommandExecutingSelector(commandNames.RESET_QUALITY_DEFINITIONS),
+ (isResettingQualityDefinitions) => {
+ return {
+ isResettingQualityDefinitions
+ };
+ }
+ );
+}
+
+class QualityConnector extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QualityConnector.propTypes = {
+ isResettingQualityDefinitions: PropTypes.bool.isRequired
+};
+
+export default connect(createMapStateToProps)(QualityConnector);
diff --git a/frontend/src/Settings/Quality/Reset/ResetQualityDefinitionsModal.js b/frontend/src/Settings/Quality/Reset/ResetQualityDefinitionsModal.js
new file mode 100644
index 000000000..ee9caa260
--- /dev/null
+++ b/frontend/src/Settings/Quality/Reset/ResetQualityDefinitionsModal.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import { sizes } from 'Helpers/Props';
+import ResetQualityDefinitionsModalContentConnector from './ResetQualityDefinitionsModalContentConnector';
+
+function ResetQualityDefinitionsModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+ResetQualityDefinitionsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ResetQualityDefinitionsModal;
diff --git a/frontend/src/Settings/Quality/Reset/ResetQualityDefinitionsModalContent.css b/frontend/src/Settings/Quality/Reset/ResetQualityDefinitionsModalContent.css
new file mode 100644
index 000000000..99c50adbe
--- /dev/null
+++ b/frontend/src/Settings/Quality/Reset/ResetQualityDefinitionsModalContent.css
@@ -0,0 +1,3 @@
+.messageContainer {
+ margin-bottom: 20px;
+}
diff --git a/frontend/src/Settings/Quality/Reset/ResetQualityDefinitionsModalContent.css.d.ts b/frontend/src/Settings/Quality/Reset/ResetQualityDefinitionsModalContent.css.d.ts
new file mode 100644
index 000000000..651320174
--- /dev/null
+++ b/frontend/src/Settings/Quality/Reset/ResetQualityDefinitionsModalContent.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'messageContainer': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Quality/Reset/ResetQualityDefinitionsModalContent.js b/frontend/src/Settings/Quality/Reset/ResetQualityDefinitionsModalContent.js
new file mode 100644
index 000000000..11f9dfa2f
--- /dev/null
+++ b/frontend/src/Settings/Quality/Reset/ResetQualityDefinitionsModalContent.js
@@ -0,0 +1,106 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import Button from 'Components/Link/Button';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import { inputTypes, kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import styles from './ResetQualityDefinitionsModalContent.css';
+
+class ResetQualityDefinitionsModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ resetDefinitionTitles: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onResetDefinitionTitlesChange = ({ value }) => {
+ this.setState({ resetDefinitionTitles: value });
+ };
+
+ onResetQualityDefinitionsConfirmed = () => {
+ const resetDefinitionTitles = this.state.resetDefinitionTitles;
+
+ this.setState({ resetDefinitionTitles: false });
+ this.props.onResetQualityDefinitions(resetDefinitionTitles);
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ onModalClose,
+ isResettingQualityDefinitions
+ } = this.props;
+
+ const resetDefinitionTitles = this.state.resetDefinitionTitles;
+
+ return (
+
+
+ {translate('ResetQualityDefinitions')}
+
+
+
+
+ {translate('ResetQualityDefinitionsMessageText')}
+
+
+
+
+ {translate('ResetTitles')}
+
+
+
+
+
+
+
+
+
+ {translate('Cancel')}
+
+
+
+ {translate('Reset')}
+
+
+
+ );
+ }
+}
+
+ResetQualityDefinitionsModalContent.propTypes = {
+ onResetQualityDefinitions: PropTypes.func.isRequired,
+ isResettingQualityDefinitions: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ResetQualityDefinitionsModalContent;
diff --git a/frontend/src/Settings/Quality/Reset/ResetQualityDefinitionsModalContentConnector.js b/frontend/src/Settings/Quality/Reset/ResetQualityDefinitionsModalContentConnector.js
new file mode 100644
index 000000000..645cac1e1
--- /dev/null
+++ b/frontend/src/Settings/Quality/Reset/ResetQualityDefinitionsModalContentConnector.js
@@ -0,0 +1,54 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import * as commandNames from 'Commands/commandNames';
+import { executeCommand } from 'Store/Actions/commandActions';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import ResetQualityDefinitionsModalContent from './ResetQualityDefinitionsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createCommandExecutingSelector(commandNames.RESET_QUALITY_DEFINITIONS),
+ (isResettingQualityDefinitions) => {
+ return {
+ isResettingQualityDefinitions
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ executeCommand
+};
+
+class ResetQualityDefinitionsModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onResetQualityDefinitions = (resetTitles) => {
+ this.props.executeCommand({ name: commandNames.RESET_QUALITY_DEFINITIONS, resetTitles });
+ this.props.onModalClose(true);
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ResetQualityDefinitionsModalContentConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ isResettingQualityDefinitions: PropTypes.bool.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ResetQualityDefinitionsModalContentConnector);
diff --git a/frontend/src/Settings/Settings.css b/frontend/src/Settings/Settings.css
new file mode 100644
index 000000000..ef69e9674
--- /dev/null
+++ b/frontend/src/Settings/Settings.css
@@ -0,0 +1,18 @@
+.link {
+ composes: link from '~Components/Link/Link.css';
+
+ border-bottom: 1px solid #e5e5e5;
+ color: var(--textColor);
+ font-size: 21px;
+
+ &:hover {
+ color: #616573;
+ text-decoration: none;
+ }
+}
+
+.summary {
+ margin-top: 10px;
+ margin-bottom: 30px;
+ color: var(--helpTextColor);
+}
diff --git a/frontend/src/Settings/Settings.css.d.ts b/frontend/src/Settings/Settings.css.d.ts
new file mode 100644
index 000000000..ad1605615
--- /dev/null
+++ b/frontend/src/Settings/Settings.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'link': string;
+ 'summary': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Settings.js b/frontend/src/Settings/Settings.js
new file mode 100644
index 000000000..d2a86adc6
--- /dev/null
+++ b/frontend/src/Settings/Settings.js
@@ -0,0 +1,156 @@
+import React from 'react';
+import Link from 'Components/Link/Link';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import translate from 'Utilities/String/translate';
+import SettingsToolbarConnector from './SettingsToolbarConnector';
+import styles from './Settings.css';
+
+function Settings() {
+ return (
+
+
+
+
+
+ {translate('MediaManagement')}
+
+
+
+ {translate('MediaManagementSettingsSummary')}
+
+
+
+ {translate('Profiles')}
+
+
+
+ {translate('ProfilesSettingsArtistSummary')}
+
+
+
+ {translate('Quality')}
+
+
+
+ {translate('QualitySettingsSummary')}
+
+
+
+ {translate('CustomFormats')}
+
+
+
+ {translate('CustomFormatsSettingsSummary')}
+
+
+
+ {translate('Indexers')}
+
+
+
+ {translate('IndexersSettingsSummary')}
+
+
+
+ {translate('DownloadClients')}
+
+
+
+ {translate('DownloadClientsSettingsSummary')}
+
+
+
+ {translate('ImportLists')}
+
+
+
+ {translate('ImportListsSettingsSummary')}
+
+
+
+ {translate('Connect')}
+
+
+
+ {translate('ConnectSettingsSummary')}
+
+
+
+ {translate('Metadata')}
+
+
+
+ {translate('MetadataSettingsArtistSummary')}
+
+
+
+ {translate('Tags')}
+
+
+
+ {translate('TagsSettingsSummary')}
+
+
+
+ {translate('General')}
+
+
+
+ {translate('GeneralSettingsSummary')}
+
+
+
+ {translate('Ui')}
+
+
+
+ {translate('UiSettingsSummary')}
+
+
+
+ );
+}
+
+Settings.propTypes = {
+};
+
+export default Settings;
diff --git a/frontend/src/Settings/SettingsToolbar.js b/frontend/src/Settings/SettingsToolbar.js
new file mode 100644
index 000000000..b36cdcaca
--- /dev/null
+++ b/frontend/src/Settings/SettingsToolbar.js
@@ -0,0 +1,106 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import { icons } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import AdvancedSettingsButton from './AdvancedSettingsButton';
+import PendingChangesModal from './PendingChangesModal';
+
+class SettingsToolbar extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.bindShortcut(shortcuts.SAVE_SETTINGS.key, this.saveSettings, { isGlobal: true });
+ }
+
+ //
+ // Control
+
+ saveSettings = (event) => {
+ event.preventDefault();
+
+ const {
+ hasPendingChanges,
+ onSavePress
+ } = this.props;
+
+ if (hasPendingChanges) {
+ onSavePress();
+ }
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ advancedSettings,
+ showSave,
+ isSaving,
+ hasPendingChanges,
+ additionalButtons,
+ hasPendingLocation,
+ onSavePress,
+ onConfirmNavigation,
+ onCancelNavigation,
+ onAdvancedSettingsPress
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ showSave &&
+
+ }
+
+ {
+ additionalButtons
+ }
+
+
+
+
+ );
+ }
+}
+
+SettingsToolbar.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ showSave: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool,
+ hasPendingLocation: PropTypes.bool.isRequired,
+ hasPendingChanges: PropTypes.bool,
+ additionalButtons: PropTypes.node,
+ onSavePress: PropTypes.func,
+ onAdvancedSettingsPress: PropTypes.func.isRequired,
+ onConfirmNavigation: PropTypes.func.isRequired,
+ onCancelNavigation: PropTypes.func.isRequired,
+ bindShortcut: PropTypes.func.isRequired
+};
+
+SettingsToolbar.defaultProps = {
+ showSave: true
+};
+
+export default keyboardShortcuts(SettingsToolbar);
diff --git a/frontend/src/Settings/SettingsToolbarConnector.js b/frontend/src/Settings/SettingsToolbarConnector.js
new file mode 100644
index 000000000..65d937ab8
--- /dev/null
+++ b/frontend/src/Settings/SettingsToolbarConnector.js
@@ -0,0 +1,148 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { withRouter } from 'react-router-dom';
+import { toggleAdvancedSettings } from 'Store/Actions/settingsActions';
+import SettingsToolbar from './SettingsToolbar';
+
+function mapStateToProps(state) {
+ return {
+ advancedSettings: state.settings.advancedSettings
+ };
+}
+
+const mapDispatchToProps = {
+ toggleAdvancedSettings
+};
+
+class SettingsToolbarConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ nextLocation: null,
+ nextLocationAction: null,
+ confirmed: false
+ };
+
+ this._unblock = null;
+ }
+
+ componentDidMount() {
+ this._unblock = this.props.history.block(this.routerWillLeave);
+ }
+
+ componentWillUnmount() {
+ if (this._unblock) {
+ this._unblock();
+ }
+ }
+
+ //
+ // Control
+
+ routerWillLeave = (nextLocation, nextLocationAction) => {
+ if (this.state.confirmed) {
+ this.setState({
+ nextLocation: null,
+ nextLocationAction: null,
+ confirmed: false
+ });
+
+ return true;
+ }
+
+ if (this.props.hasPendingChanges ) {
+ this.setState({
+ nextLocation,
+ nextLocationAction
+ });
+
+ return false;
+ }
+
+ return true;
+ };
+
+ //
+ // Listeners
+
+ onAdvancedSettingsPress = () => {
+ this.props.toggleAdvancedSettings();
+ };
+
+ onConfirmNavigation = () => {
+ const {
+ nextLocation,
+ nextLocationAction
+ } = this.state;
+
+ const history = this.props.history;
+
+ const path = `${nextLocation.pathname}${nextLocation.search}`;
+
+ this.setState({
+ confirmed: true
+ }, () => {
+ if (nextLocationAction === 'PUSH') {
+ history.push(path);
+ } else {
+ // Unfortunately back and forward both use POP,
+ // which means we don't actually know which direction
+ // the user wanted to go, assuming back.
+
+ history.goBack();
+ }
+ });
+ };
+
+ onCancelNavigation = () => {
+ this.setState({
+ nextLocation: null,
+ nextLocationAction: null,
+ confirmed: false
+ });
+ };
+
+ //
+ // Render
+
+ render() {
+ const hasPendingLocation = this.state.nextLocation !== null;
+
+ return (
+
+ );
+ }
+}
+
+const historyShape = {
+ block: PropTypes.func.isRequired,
+ goBack: PropTypes.func.isRequired,
+ push: PropTypes.func.isRequired
+};
+
+SettingsToolbarConnector.propTypes = {
+ showSave: PropTypes.bool,
+ hasPendingChanges: PropTypes.bool.isRequired,
+ history: PropTypes.shape(historyShape).isRequired,
+ onSavePress: PropTypes.func,
+ toggleAdvancedSettings: PropTypes.func.isRequired
+};
+
+SettingsToolbarConnector.defaultProps = {
+ hasPendingChanges: false
+};
+
+export default withRouter(connect(mapStateToProps, mapDispatchToProps)(SettingsToolbarConnector));
diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css
new file mode 100644
index 000000000..b1e2de95b
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css
@@ -0,0 +1,38 @@
+.autoTagging {
+ composes: card from '~Components/Card.css';
+
+ width: 300px;
+}
+
+.nameContainer {
+ display: flex;
+ justify-content: space-between;
+}
+
+.name {
+ @add-mixin truncate;
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.cloneButton {
+ composes: button from '~Components/Link/IconButton.css';
+
+ height: 36px;
+}
+
+.formats {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+ pointer-events: all;
+}
+
+.tooltipLabel {
+ composes: label from '~Components/Label.css';
+
+ margin: 0;
+ border: none;
+}
diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css.d.ts
new file mode 100644
index 000000000..b6b665429
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css.d.ts
@@ -0,0 +1,12 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'autoTagging': string;
+ 'cloneButton': string;
+ 'formats': string;
+ 'name': string;
+ 'nameContainer': string;
+ 'tooltipLabel': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTagging.js b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.js
new file mode 100644
index 000000000..760273cb3
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.js
@@ -0,0 +1,136 @@
+import PropTypes from 'prop-types';
+import React, { useCallback, useState } from 'react';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import IconButton from 'Components/Link/IconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import TagList from 'Components/TagList';
+import { icons, kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import EditAutoTaggingModal from './EditAutoTaggingModal';
+import styles from './AutoTagging.css';
+
+export default function AutoTagging(props) {
+ const {
+ id,
+ name,
+ tags,
+ tagList,
+ specifications,
+ isDeleting,
+ onConfirmDeleteAutoTagging,
+ onCloneAutoTaggingPress
+ } = props;
+
+ const [isEditModalOpen, setIsEditModalOpen] = useState(false);
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+
+ const onEditPress = useCallback(() => {
+ setIsEditModalOpen(true);
+ }, [setIsEditModalOpen]);
+
+ const onEditModalClose = useCallback(() => {
+ setIsEditModalOpen(false);
+ }, [setIsEditModalOpen]);
+
+ const onDeletePress = useCallback(() => {
+ setIsEditModalOpen(false);
+ setIsDeleteModalOpen(true);
+ }, [setIsEditModalOpen, setIsDeleteModalOpen]);
+
+ const onDeleteModalClose = useCallback(() => {
+ setIsDeleteModalOpen(false);
+ }, [setIsDeleteModalOpen]);
+
+ const onConfirmDelete = useCallback(() => {
+ onConfirmDeleteAutoTagging(id);
+ }, [id, onConfirmDeleteAutoTagging]);
+
+ const onClonePress = useCallback(() => {
+ onCloneAutoTaggingPress(id);
+ }, [id, onCloneAutoTaggingPress]);
+
+ return (
+
+
+
+
+
+
+ {
+ specifications.map((item, index) => {
+ if (!item) {
+ return null;
+ }
+
+ let kind = kinds.DEFAULT;
+ if (item.required) {
+ kind = kinds.SUCCESS;
+ }
+ if (item.negate) {
+ kind = kinds.DANGER;
+ }
+
+ return (
+
+ {item.name}
+
+ );
+ })
+ }
+
+
+
+
+
+
+ );
+}
+
+AutoTagging.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ specifications: PropTypes.arrayOf(PropTypes.object).isRequired,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDeleting: PropTypes.bool.isRequired,
+ onConfirmDeleteAutoTagging: PropTypes.func.isRequired,
+ onCloneAutoTaggingPress: PropTypes.func.isRequired
+};
diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css
new file mode 100644
index 000000000..40950bd5f
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css
@@ -0,0 +1,21 @@
+.autoTaggings {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addAutoTagging {
+ composes: autoTagging from '~./AutoTagging.css';
+
+ background-color: var(--cardAlternateBackgroundColor);
+ color: var(--gray);
+ text-align: center;
+ font-size: 45px;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid var(--borderColor);
+ border-radius: 4px;
+ background-color: var(--cardCenterBackgroundColor);
+}
diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css.d.ts
new file mode 100644
index 000000000..ef3094d3b
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'addAutoTagging': string;
+ 'autoTaggings': string;
+ 'center': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.js b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.js
new file mode 100644
index 000000000..005547bb7
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.js
@@ -0,0 +1,108 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import Card from 'Components/Card';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import { icons } from 'Helpers/Props';
+import {
+ cloneAutoTagging,
+ deleteAutoTagging,
+ fetchAutoTaggings,
+ fetchRootFolders
+} from 'Store/Actions/settingsActions';
+import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import sortByProp from 'Utilities/Array/sortByProp';
+import translate from 'Utilities/String/translate';
+import AutoTagging from './AutoTagging';
+import EditAutoTaggingModal from './EditAutoTaggingModal';
+import styles from './AutoTaggings.css';
+
+export default function AutoTaggings() {
+ const {
+ error,
+ items,
+ isDeleting,
+ isFetching,
+ isPopulated
+ } = useSelector(
+ createSortedSectionSelector('settings.autoTaggings', sortByProp('name'))
+ );
+
+ const tagList = useSelector(createTagsSelector());
+ const dispatch = useDispatch();
+ const [isEditModalOpen, setIsEditModalOpen] = useState(false);
+ const [tagsFromId, setTagsFromId] = useState(undefined);
+
+ const onClonePress = useCallback((id) => {
+ dispatch(cloneAutoTagging({ id }));
+
+ setTagsFromId(id);
+ setIsEditModalOpen(true);
+ }, [dispatch, setIsEditModalOpen]);
+
+ const onEditPress = useCallback(() => {
+ setIsEditModalOpen(true);
+ }, [setIsEditModalOpen]);
+
+ const onEditModalClose = useCallback(() => {
+ setIsEditModalOpen(false);
+ }, [setIsEditModalOpen]);
+
+ const onConfirmDelete = useCallback((id) => {
+ dispatch(deleteAutoTagging({ id }));
+ }, [dispatch]);
+
+ useEffect(() => {
+ dispatch(fetchAutoTaggings());
+ dispatch(fetchRootFolders());
+ }, [dispatch]);
+
+ return (
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModal.js b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModal.js
new file mode 100644
index 000000000..c6f810785
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModal.js
@@ -0,0 +1,50 @@
+import PropTypes from 'prop-types';
+import React, { useCallback, useState } from 'react';
+import { useDispatch } from 'react-redux';
+import Modal from 'Components/Modal/Modal';
+import { sizes } from 'Helpers/Props';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditAutoTaggingModalContent from './EditAutoTaggingModalContent';
+
+export default function EditAutoTaggingModal(props) {
+ const {
+ isOpen,
+ onModalClose: onOriginalModalClose,
+ ...otherProps
+ } = props;
+
+ const dispatch = useDispatch();
+ const [height, setHeight] = useState('auto');
+
+ const onContentHeightChange = useCallback((h) => {
+ if (height === 'auto' || h > height) {
+ setHeight(h);
+ }
+ }, [height, setHeight]);
+
+ const onModalClose = useCallback(() => {
+ dispatch(clearPendingChanges({ section: 'settings.autoTaggings' }));
+ onOriginalModalClose();
+ }, [dispatch, onOriginalModalClose]);
+
+ return (
+
+
+
+ );
+}
+
+EditAutoTaggingModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css
new file mode 100644
index 000000000..d503b0af3
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css
@@ -0,0 +1,32 @@
+.deleteButton {
+ composes: button from '~Components/Link/Button.css';
+
+ margin-right: auto;
+}
+
+.rightButtons {
+ justify-content: flex-end;
+ margin-right: auto;
+}
+
+.addSpecification {
+ composes: autoTagging from '~./AutoTagging.css';
+
+ background-color: var(--cardAlternateBackgroundColor);
+ color: var(--gray);
+ text-align: center;
+ font-size: 45px;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid var(--borderColor);
+ border-radius: 4px;
+ background-color: var(--cardCenterBackgroundColor);
+}
+
+.autoTaggings {
+ display: flex;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css.d.ts
new file mode 100644
index 000000000..2a7f6b41e
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css.d.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'addSpecification': string;
+ 'autoTaggings': string;
+ 'center': string;
+ 'deleteButton': string;
+ 'rightButtons': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.js b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.js
new file mode 100644
index 000000000..01a5e846b
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.js
@@ -0,0 +1,269 @@
+import PropTypes from 'prop-types';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import Card from 'Components/Card';
+import FieldSet from 'Components/FieldSet';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import Icon from 'Components/Icon';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import { icons, inputTypes, kinds } from 'Helpers/Props';
+import {
+ cloneAutoTaggingSpecification,
+ deleteAutoTaggingSpecification,
+ fetchAutoTaggingSpecifications,
+ saveAutoTagging,
+ setAutoTaggingValue
+} from 'Store/Actions/settingsActions';
+import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector';
+import translate from 'Utilities/String/translate';
+import AddSpecificationModal from './Specifications/AddSpecificationModal';
+import EditSpecificationModal from './Specifications/EditSpecificationModal';
+import Specification from './Specifications/Specification';
+import styles from './EditAutoTaggingModalContent.css';
+
+export default function EditAutoTaggingModalContent(props) {
+ const {
+ id,
+ tagsFromId,
+ onModalClose,
+ onDeleteAutoTaggingPress
+ } = props;
+
+ const {
+ error,
+ item,
+ isFetching,
+ isSaving,
+ saveError,
+ validationErrors,
+ validationWarnings
+ } = useSelector(createProviderSettingsSelectorHook('autoTaggings', id));
+
+ const {
+ isPopulated: specificationsPopulated,
+ items: specifications
+ } = useSelector((state) => state.settings.autoTaggingSpecifications);
+
+ const dispatch = useDispatch();
+ const [isAddSpecificationModalOpen, setIsAddSpecificationModalOpen] = useState(false);
+ const [isEditSpecificationModalOpen, setIsEditSpecificationModalOpen] = useState(false);
+ // const [isImportAutoTaggingModalOpen, setIsImportAutoTaggingModalOpen] = useState(false);
+
+ const onAddSpecificationPress = useCallback(() => {
+ setIsAddSpecificationModalOpen(true);
+ }, [setIsAddSpecificationModalOpen]);
+
+ const onAddSpecificationModalClose = useCallback(({ specificationSelected = false } = {}) => {
+ setIsAddSpecificationModalOpen(false);
+ setIsEditSpecificationModalOpen(specificationSelected);
+ }, [setIsAddSpecificationModalOpen]);
+
+ const onEditSpecificationModalClose = useCallback(() => {
+ setIsEditSpecificationModalOpen(false);
+ }, [setIsEditSpecificationModalOpen]);
+
+ const onInputChange = useCallback(({ name, value }) => {
+ dispatch(setAutoTaggingValue({ name, value }));
+ }, [dispatch]);
+
+ const onSavePress = useCallback(() => {
+ dispatch(saveAutoTagging({ id }));
+ }, [dispatch, id]);
+
+ const onCloneSpecificationPress = useCallback((specId) => {
+ dispatch(cloneAutoTaggingSpecification({ id: specId }));
+ }, [dispatch]);
+
+ const onConfirmDeleteSpecification = useCallback((specId) => {
+ dispatch(deleteAutoTaggingSpecification({ id: specId }));
+ }, [dispatch]);
+
+ useEffect(() => {
+ dispatch(fetchAutoTaggingSpecifications({ id: tagsFromId || id }));
+ }, [id, tagsFromId, dispatch]);
+
+ const isSavingRef = useRef();
+
+ useEffect(() => {
+ if (isSavingRef.current && !isSaving && !saveError) {
+ onModalClose();
+ }
+
+ isSavingRef.current = isSaving;
+ }, [isSaving, saveError, onModalClose]);
+
+ const {
+ name,
+ removeTagsAutomatically,
+ tags
+ } = item;
+
+ return (
+
+
+
+ {id ? translate('EditAutoTag') : translate('AddAutoTag')}
+
+
+
+
+ {
+ isFetching ?
: null
+ }
+
+ {
+ !isFetching && !!error ?
+
+ {translate('AddAutoTagError')}
+
:
+ null
+ }
+
+ {
+ !isFetching && !error && specificationsPopulated ?
+
+
+
+
+
+ {
+ specifications.map((tag) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
*/}
+
+
:
+ null
+ }
+
+
+
+
+ {
+ id ?
+
+ {translate('Delete')}
+ :
+ null
+ }
+
+ {/*
+ Import
+ */}
+
+
+
+ {translate('Cancel')}
+
+
+
+ {translate('Save')}
+
+
+
+ );
+}
+
+EditAutoTaggingModalContent.propTypes = {
+ id: PropTypes.number,
+ tagsFromId: PropTypes.number,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteAutoTaggingPress: PropTypes.func
+};
diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css
new file mode 100644
index 000000000..eabcae750
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css
@@ -0,0 +1,44 @@
+.specification {
+ composes: card from '~Components/Card.css';
+
+ position: relative;
+ width: 300px;
+ height: 100px;
+}
+
+.underlay {
+ @add-mixin cover;
+}
+
+.overlay {
+ @add-mixin linkOverlay;
+
+ padding: 10px;
+}
+
+.name {
+ text-align: center;
+ font-weight: lighter;
+ font-size: 24px;
+}
+
+.actions {
+ margin-top: 20px;
+ text-align: right;
+}
+
+.presetsMenu {
+ composes: menu from '~Components/Menu/Menu.css';
+
+ display: inline-block;
+ margin: 0 5px;
+}
+
+.presetsMenuButton {
+ composes: button from '~Components/Link/Button.css';
+
+ &::after {
+ margin-left: 5px;
+ content: '\25BE';
+ }
+}
diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css.d.ts
new file mode 100644
index 000000000..7f8a93de9
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css.d.ts
@@ -0,0 +1,13 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'actions': string;
+ 'name': string;
+ 'overlay': string;
+ 'presetsMenu': string;
+ 'presetsMenuButton': string;
+ 'specification': string;
+ 'underlay': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.js
new file mode 100644
index 000000000..f6f2b134e
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.js
@@ -0,0 +1,101 @@
+import PropTypes from 'prop-types';
+import React, { useCallback } from 'react';
+import Button from 'Components/Link/Button';
+import Link from 'Components/Link/Link';
+import Menu from 'Components/Menu/Menu';
+import MenuContent from 'Components/Menu/MenuContent';
+import { sizes } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import AddSpecificationPresetMenuItem from './AddSpecificationPresetMenuItem';
+import styles from './AddSpecificationItem.css';
+
+export default function AddSpecificationItem(props) {
+ const {
+ implementation,
+ implementationName,
+ infoLink,
+ presets,
+ onSpecificationSelect
+ } = props;
+
+ const onWrappedSpecificationSelect = useCallback(() => {
+ onSpecificationSelect({ implementation });
+ }, [implementation, onSpecificationSelect]);
+
+ const hasPresets = !!presets && !!presets.length;
+
+ return (
+
+
+
+
+
+ {implementationName}
+
+
+
+ {
+ hasPresets ?
+
+
+ {translate('Custom')}
+
+
+
+
+ {translate('Presets')}
+
+
+
+ {
+ presets.map((preset, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+ :
+ null
+ }
+
+ {
+ infoLink ?
+
+ {translate('MoreInfo')}
+ :
+ null
+ }
+
+
+
+ );
+}
+
+AddSpecificationItem.propTypes = {
+ implementation: PropTypes.string.isRequired,
+ implementationName: PropTypes.string.isRequired,
+ infoLink: PropTypes.string,
+ presets: PropTypes.arrayOf(PropTypes.object),
+ onSpecificationSelect: PropTypes.func.isRequired
+};
diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModal.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModal.js
new file mode 100644
index 000000000..1a8c115f0
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AddSpecificationModalContent from './AddSpecificationModalContent';
+
+function AddSpecificationModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+AddSpecificationModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddSpecificationModal;
diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css
new file mode 100644
index 000000000..d51349ea9
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css
@@ -0,0 +1,5 @@
+.specifications {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css.d.ts
new file mode 100644
index 000000000..83fbf5804
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'specifications': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.js
new file mode 100644
index 000000000..454a2591a
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.js
@@ -0,0 +1,106 @@
+import PropTypes from 'prop-types';
+import React, { useCallback, useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import Alert from 'Components/Alert';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import { kinds } from 'Helpers/Props';
+import {
+ fetchAutoTaggingSpecificationSchema,
+ selectAutoTaggingSpecificationSchema
+} from 'Store/Actions/settingsActions';
+import translate from 'Utilities/String/translate';
+import AddSpecificationItem from './AddSpecificationItem';
+import styles from './AddSpecificationModalContent.css';
+
+export default function AddSpecificationModalContent(props) {
+ const {
+ onModalClose
+ } = props;
+
+ const {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ schema
+ } = useSelector(
+ (state) => state.settings.autoTaggingSpecifications
+ );
+
+ const dispatch = useDispatch();
+
+ const onSpecificationSelect = useCallback(({ implementation, name }) => {
+ dispatch(selectAutoTaggingSpecificationSchema({ implementation, presetName: name }));
+ onModalClose({ specificationSelected: true });
+ }, [dispatch, onModalClose]);
+
+ useEffect(() => {
+ dispatch(fetchAutoTaggingSpecificationSchema());
+ }, [dispatch]);
+
+ return (
+
+
+ {translate('AddCondition')}
+
+
+
+ {
+ isSchemaFetching ? : null
+ }
+
+ {
+ !isSchemaFetching && !!schemaError ?
+
+ {translate('AddConditionError')}
+
:
+ null
+ }
+
+ {
+ isSchemaPopulated && !schemaError ?
+
+
+
+
+ {translate('SupportedAutoTaggingProperties')}
+
+
+
+
+ {
+ schema.map((specification) => {
+ return (
+
+ );
+ })
+ }
+
+
+
:
+ null
+ }
+
+
+
+
+ {translate('Close')}
+
+
+
+ );
+}
+
+AddSpecificationModalContent.propTypes = {
+ onModalClose: PropTypes.func.isRequired
+};
diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationPresetMenuItem.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationPresetMenuItem.js
new file mode 100644
index 000000000..b043ddf06
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationPresetMenuItem.js
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React, { useCallback } from 'react';
+import MenuItem from 'Components/Menu/MenuItem';
+
+export default function AddSpecificationPresetMenuItem(props) {
+ const {
+ name,
+ implementation,
+ onPress,
+ ...otherProps
+ } = props;
+
+ const onWrappedPress = useCallback(() => {
+ onPress({
+ name,
+ implementation
+ });
+ }, [name, implementation, onPress]);
+
+ return (
+
+ {name}
+
+ );
+}
+
+AddSpecificationPresetMenuItem.propTypes = {
+ name: PropTypes.string.isRequired,
+ implementation: PropTypes.string.isRequired,
+ onPress: PropTypes.func.isRequired
+};
diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModal.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModal.js
new file mode 100644
index 000000000..16ed4daec
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModal.js
@@ -0,0 +1,36 @@
+import PropTypes from 'prop-types';
+import React, { useCallback } from 'react';
+import { useDispatch } from 'react-redux';
+import Modal from 'Components/Modal/Modal';
+import { sizes } from 'Helpers/Props';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditSpecificationModalContent from './EditSpecificationModalContent';
+
+function EditSpecificationModal({ isOpen, onModalClose, ...otherProps }) {
+ const dispatch = useDispatch();
+
+ const onWrappedModalClose = useCallback(() => {
+ dispatch(clearPendingChanges({ section: 'settings.autoTaggingSpecifications' }));
+ onModalClose();
+ }, [onModalClose, dispatch]);
+
+ return (
+
+
+
+ );
+}
+
+EditSpecificationModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditSpecificationModal;
diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css
new file mode 100644
index 000000000..a2b6014df
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css
@@ -0,0 +1,5 @@
+.deleteButton {
+ composes: button from '~Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css.d.ts
new file mode 100644
index 000000000..c5f0ef8a7
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'deleteButton': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js
new file mode 100644
index 000000000..04302729b
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js
@@ -0,0 +1,190 @@
+import PropTypes from 'prop-types';
+import React, { useCallback } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import Alert from 'Components/Alert';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import { inputTypes, kinds } from 'Helpers/Props';
+import {
+ clearAutoTaggingSpecificationPending,
+ saveAutoTaggingSpecification,
+ setAutoTaggingSpecificationFieldValue,
+ setAutoTaggingSpecificationValue
+} from 'Store/Actions/settingsActions';
+import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector';
+import translate from 'Utilities/String/translate';
+import styles from './EditSpecificationModalContent.css';
+
+function EditSpecificationModalContent(props) {
+ const {
+ id,
+ onDeleteSpecificationPress,
+ onModalClose
+ } = props;
+
+ const advancedSettings = useSelector((state) => state.settings.advancedSettings);
+
+ const {
+ item,
+ ...otherFormProps
+ } = useSelector(
+ createProviderSettingsSelectorHook('autoTaggingSpecifications', id)
+ );
+
+ const dispatch = useDispatch();
+
+ const onInputChange = useCallback(({ name, value }) => {
+ dispatch(setAutoTaggingSpecificationValue({ name, value }));
+ }, [dispatch]);
+
+ const onFieldChange = useCallback(({ name, value }) => {
+ dispatch(setAutoTaggingSpecificationFieldValue({ name, value }));
+ }, [dispatch]);
+
+ const onCancelPress = useCallback(({ name, value }) => {
+ dispatch(clearAutoTaggingSpecificationPending());
+ onModalClose();
+ }, [dispatch, onModalClose]);
+
+ const onSavePress = useCallback(({ name, value }) => {
+ dispatch(saveAutoTaggingSpecification({ id }));
+ onModalClose();
+ }, [dispatch, id, onModalClose]);
+
+ const {
+ implementationName,
+ name,
+ negate,
+ required,
+ fields
+ } = item;
+
+ return (
+
+
+ {id ? translate('EditConditionImplementation', { implementationName }) : translate('AddConditionImplementation', { implementationName })}
+
+
+
+
+
+
+ {
+ id ?
+
+ {translate('Delete')}
+ :
+ null
+ }
+
+
+ {translate('Cancel')}
+
+
+
+ {translate('Save')}
+
+
+
+ );
+}
+
+EditSpecificationModalContent.propTypes = {
+ id: PropTypes.number,
+ onDeleteSpecificationPress: PropTypes.func,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditSpecificationModalContent;
diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContentConnector.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContentConnector.js
new file mode 100644
index 000000000..8f27b74e0
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContentConnector.js
@@ -0,0 +1,78 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { clearAutoTaggingSpecificationPending, saveAutoTaggingSpecification, setAutoTaggingSpecificationFieldValue, setAutoTaggingSpecificationValue } from 'Store/Actions/settingsActions';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import EditSpecificationModalContent from './EditSpecificationModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createProviderSettingsSelector('autoTaggingSpecifications'),
+ (advancedSettings, specification) => {
+ return {
+ advancedSettings,
+ ...specification
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setAutoTaggingSpecificationValue,
+ setAutoTaggingSpecificationFieldValue,
+ saveAutoTaggingSpecification,
+ clearAutoTaggingSpecificationPending
+};
+
+class EditSpecificationModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setAutoTaggingSpecificationValue({ name, value });
+ };
+
+ onFieldChange = ({ name, value }) => {
+ this.props.setAutoTaggingSpecificationFieldValue({ name, value });
+ };
+
+ onCancelPress = () => {
+ this.props.clearAutoTaggingSpecificationPending();
+ this.props.onModalClose();
+ };
+
+ onSavePress = () => {
+ this.props.saveAutoTaggingSpecification({ id: this.props.id });
+ this.props.onModalClose();
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditSpecificationModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ item: PropTypes.object.isRequired,
+ setAutoTaggingSpecificationValue: PropTypes.func.isRequired,
+ setAutoTaggingSpecificationFieldValue: PropTypes.func.isRequired,
+ clearAutoTaggingSpecificationPending: PropTypes.func.isRequired,
+ saveAutoTaggingSpecification: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditSpecificationModalContentConnector);
diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css
new file mode 100644
index 000000000..e329fc313
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css
@@ -0,0 +1,38 @@
+.autoTagging {
+ composes: card from '~Components/Card.css';
+
+ width: 300px;
+}
+
+.nameContainer {
+ display: flex;
+ justify-content: space-between;
+}
+
+.name {
+ @add-mixin truncate;
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.cloneButton {
+ composes: button from '~Components/Link/IconButton.css';
+
+ height: 36px;
+}
+
+.labels {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+ pointer-events: all;
+}
+
+.tooltipLabel {
+ composes: label from '~Components/Label.css';
+
+ margin: 0;
+ border: none;
+}
diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css.d.ts
new file mode 100644
index 000000000..b3229d715
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css.d.ts
@@ -0,0 +1,12 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'autoTagging': string;
+ 'cloneButton': string;
+ 'labels': string;
+ 'name': string;
+ 'nameContainer': string;
+ 'tooltipLabel': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.js
new file mode 100644
index 000000000..21977e160
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.js
@@ -0,0 +1,122 @@
+import PropTypes from 'prop-types';
+import React, { useCallback, useState } from 'react';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import IconButton from 'Components/Link/IconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import { icons, kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import EditSpecificationModal from './EditSpecificationModal';
+import styles from './Specification.css';
+
+export default function Specification(props) {
+ const {
+ id,
+ implementationName,
+ name,
+ required,
+ negate,
+ onConfirmDeleteSpecification,
+ onCloneSpecificationPress
+ } = props;
+
+ const [isEditModalOpen, setIsEditModalOpen] = useState(false);
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+
+ const onEditPress = useCallback(() => {
+ setIsEditModalOpen(true);
+ }, [setIsEditModalOpen]);
+
+ const onEditModalClose = useCallback(() => {
+ setIsEditModalOpen(false);
+ }, [setIsEditModalOpen]);
+
+ const onDeletePress = useCallback(() => {
+ setIsEditModalOpen(false);
+ setIsDeleteModalOpen(true);
+ }, [setIsEditModalOpen, setIsDeleteModalOpen]);
+
+ const onDeleteModalClose = useCallback(() => {
+ setIsDeleteModalOpen(false);
+ }, [setIsDeleteModalOpen]);
+
+ const onConfirmDelete = useCallback(() => {
+ onConfirmDeleteSpecification(id);
+ }, [id, onConfirmDeleteSpecification]);
+
+ const onClonePress = useCallback(() => {
+ onCloneSpecificationPress(id);
+ }, [id, onCloneSpecificationPress]);
+
+ return (
+
+
+
+
+
+ {implementationName}
+
+
+ {
+ negate ?
+
+ {translate('Negated')}
+ :
+ null
+ }
+
+ {
+ required ?
+
+ {translate('Required')}
+ :
+ null
+ }
+
+
+
+
+
+
+ );
+}
+
+Specification.propTypes = {
+ id: PropTypes.number.isRequired,
+ implementation: PropTypes.string.isRequired,
+ implementationName: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ negate: PropTypes.bool.isRequired,
+ required: PropTypes.bool.isRequired,
+ fields: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteSpecification: PropTypes.func.isRequired,
+ onCloneSpecificationPress: PropTypes.func.isRequired
+};
diff --git a/frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js b/frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js
new file mode 100644
index 000000000..ab670359b
--- /dev/null
+++ b/frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js
@@ -0,0 +1,47 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import titleCase from 'Utilities/String/titleCase';
+
+function TagDetailsDelayProfile(props) {
+ const {
+ preferredProtocol,
+ enableUsenet,
+ enableTorrent,
+ usenetDelay,
+ torrentDelay
+ } = props;
+
+ return (
+
+
+ Protocol: {titleCase(preferredProtocol)}
+
+
+
+ {
+ enableUsenet ?
+ `Usenet Delay: ${usenetDelay}` :
+ 'Usenet disabled'
+ }
+
+
+
+ {
+ enableTorrent ?
+ `Torrent Delay: ${torrentDelay}` :
+ 'Torrents disabled'
+ }
+
+
+ );
+}
+
+TagDetailsDelayProfile.propTypes = {
+ preferredProtocol: PropTypes.string.isRequired,
+ enableUsenet: PropTypes.bool.isRequired,
+ enableTorrent: PropTypes.bool.isRequired,
+ usenetDelay: PropTypes.number.isRequired,
+ torrentDelay: PropTypes.number.isRequired
+};
+
+export default TagDetailsDelayProfile;
diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModal.js b/frontend/src/Settings/Tags/Details/TagDetailsModal.js
new file mode 100644
index 000000000..4195c64db
--- /dev/null
+++ b/frontend/src/Settings/Tags/Details/TagDetailsModal.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import { sizes } from 'Helpers/Props';
+import TagDetailsModalContentConnector from './TagDetailsModalContentConnector';
+
+function TagDetailsModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+TagDetailsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default TagDetailsModal;
diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css
new file mode 100644
index 000000000..75b157063
--- /dev/null
+++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css
@@ -0,0 +1,26 @@
+.items {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.item {
+ flex: 0 0 100%;
+}
+
+.restriction {
+ margin-bottom: 5px;
+ padding-bottom: 5px;
+ border-bottom: 1px solid var(--borderColor);
+
+ &:last-child {
+ margin: 0;
+ padding: 0;
+ border-bottom: none;
+ }
+}
+
+.deleteButton {
+ composes: button from '~Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css.d.ts b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css.d.ts
new file mode 100644
index 000000000..81112f53f
--- /dev/null
+++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css.d.ts
@@ -0,0 +1,10 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'deleteButton': string;
+ 'item': string;
+ 'items': string;
+ 'restriction': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js
new file mode 100644
index 000000000..78372d5a3
--- /dev/null
+++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js
@@ -0,0 +1,257 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import FieldSet from 'Components/FieldSet';
+import Label from 'Components/Label';
+import Button from 'Components/Link/Button';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import { kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import TagDetailsDelayProfile from './TagDetailsDelayProfile';
+import styles from './TagDetailsModalContent.css';
+
+function TagDetailsModalContent(props) {
+ const {
+ label,
+ isTagUsed,
+ artist,
+ delayProfiles,
+ importLists,
+ notifications,
+ releaseProfiles,
+ indexers,
+ downloadClients,
+ autoTags,
+ onModalClose,
+ onDeleteTagPress
+ } = props;
+
+ return (
+
+
+ Tag Details - {label}
+
+
+
+ {
+ !isTagUsed &&
+
+ {translate('TagIsNotUsedAndCanBeDeleted')}
+
+ }
+
+ {
+ artist.length ?
+
+ {
+ artist.map((item) => {
+ return (
+
+ {item.artistName}
+
+ );
+ })
+ }
+ :
+ null
+ }
+
+ {
+ delayProfiles.length ?
+
+ {
+ delayProfiles.map((item) => {
+ const {
+ id,
+ preferredProtocol,
+ enableUsenet,
+ enableTorrent,
+ usenetDelay,
+ torrentDelay
+ } = item;
+
+ return (
+
+ );
+ })
+ }
+ :
+ null
+ }
+
+ {
+ notifications.length ?
+
+ {
+ notifications.map((item) => {
+ return (
+
+ {item.name}
+
+ );
+ })
+ }
+ :
+ null
+ }
+
+ {
+ importLists.length ?
+
+ {
+ importLists.map((item) => {
+ return (
+
+ {item.name}
+
+ );
+ })
+ }
+ :
+ null
+ }
+
+ {
+ releaseProfiles.length ?
+
+ {
+ releaseProfiles.map((item) => {
+ return (
+
+
+ {
+ item.required.map((r) => {
+ return (
+
+ {r}
+
+ );
+ })
+ }
+
+
+
+ {
+ item.ignored.map((i) => {
+ return (
+
+ {i}
+
+ );
+ })
+ }
+
+
+ );
+ })
+ }
+ :
+ null
+ }
+
+ {
+ indexers.length ?
+
+ {
+ indexers.map((item) => {
+ return (
+
+ {item.name}
+
+ );
+ })
+ }
+ :
+ null
+ }
+
+ {
+ downloadClients.length ?
+
+ {
+ downloadClients.map((item) => {
+ return (
+
+ {item.name}
+
+ );
+ })
+ }
+ :
+ null
+ }
+
+ {
+ autoTags.length ?
+
+ {
+ autoTags.map((item) => {
+ return (
+
+ {item.name}
+
+ );
+ })
+ }
+ :
+ null
+ }
+
+
+
+ {
+
+ Delete
+
+ }
+
+
+ Close
+
+
+
+ );
+}
+
+TagDetailsModalContent.propTypes = {
+ label: PropTypes.string.isRequired,
+ isTagUsed: PropTypes.bool.isRequired,
+ artist: PropTypes.arrayOf(PropTypes.object).isRequired,
+ delayProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ importLists: PropTypes.arrayOf(PropTypes.object).isRequired,
+ notifications: PropTypes.arrayOf(PropTypes.object).isRequired,
+ releaseProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ indexers: PropTypes.arrayOf(PropTypes.object).isRequired,
+ downloadClients: PropTypes.arrayOf(PropTypes.object).isRequired,
+ autoTags: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteTagPress: PropTypes.func.isRequired
+};
+
+export default TagDetailsModalContent;
diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js
new file mode 100644
index 000000000..ddd70b253
--- /dev/null
+++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js
@@ -0,0 +1,121 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import TagDetailsModalContent from './TagDetailsModalContent';
+
+function findMatchingItems(ids, items) {
+ return items.filter((s) => {
+ return ids.includes(s.id);
+ });
+}
+
+function createUnorderedMatchingArtistSelector() {
+ return createSelector(
+ (state, { artistIds }) => artistIds,
+ createAllArtistSelector(),
+ findMatchingItems
+ );
+}
+
+function createMatchingArtistSelector() {
+ return createSelector(
+ createUnorderedMatchingArtistSelector(),
+ (artists) => {
+ return artists.sort((artistA, artistB) => {
+ const sortNameA = artistA.sortName;
+ const sortNameB = artistB.sortName;
+
+ if (sortNameA > sortNameB) {
+ return 1;
+ } else if (sortNameA < sortNameB) {
+ return -1;
+ }
+
+ return 0;
+ });
+ }
+ );
+}
+
+function createMatchingDelayProfilesSelector() {
+ return createSelector(
+ (state, { delayProfileIds }) => delayProfileIds,
+ (state) => state.settings.delayProfiles.items,
+ findMatchingItems
+ );
+}
+
+function createMatchingImportListsSelector() {
+ return createSelector(
+ (state, { importListIds }) => importListIds,
+ (state) => state.settings.importLists.items,
+ findMatchingItems
+ );
+}
+
+function createMatchingNotificationsSelector() {
+ return createSelector(
+ (state, { notificationIds }) => notificationIds,
+ (state) => state.settings.notifications.items,
+ findMatchingItems
+ );
+}
+
+function createMatchingReleaseProfilesSelector() {
+ return createSelector(
+ (state, { restrictionIds }) => restrictionIds,
+ (state) => state.settings.releaseProfiles.items,
+ findMatchingItems
+ );
+}
+
+function createMatchingIndexersSelector() {
+ return createSelector(
+ (state, { indexerIds }) => indexerIds,
+ (state) => state.settings.indexers.items,
+ findMatchingItems
+ );
+}
+
+function createMatchingDownloadClientsSelector() {
+ return createSelector(
+ (state, { downloadClientIds }) => downloadClientIds,
+ (state) => state.settings.downloadClients.items,
+ findMatchingItems
+ );
+}
+
+function createMatchingAutoTagsSelector() {
+ return createSelector(
+ (state, { autoTagIds }) => autoTagIds,
+ (state) => state.settings.autoTaggings.items,
+ findMatchingItems
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createMatchingArtistSelector(),
+ createMatchingDelayProfilesSelector(),
+ createMatchingImportListsSelector(),
+ createMatchingNotificationsSelector(),
+ createMatchingReleaseProfilesSelector(),
+ createMatchingIndexersSelector(),
+ createMatchingDownloadClientsSelector(),
+ createMatchingAutoTagsSelector(),
+ (artist, delayProfiles, importLists, notifications, releaseProfiles, indexers, downloadClients, autoTags) => {
+ return {
+ artist,
+ delayProfiles,
+ importLists,
+ notifications,
+ releaseProfiles,
+ indexers,
+ downloadClients,
+ autoTags
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(TagDetailsModalContent);
diff --git a/frontend/src/Settings/Tags/Tag.css b/frontend/src/Settings/Tags/Tag.css
new file mode 100644
index 000000000..ebf61e539
--- /dev/null
+++ b/frontend/src/Settings/Tags/Tag.css
@@ -0,0 +1,12 @@
+.tag {
+ composes: card from '~Components/Card.css';
+
+ flex: 150px 0 1;
+}
+
+.label {
+ margin-bottom: 20px;
+ white-space: nowrap;
+ font-weight: 300;
+ font-size: 24px;
+}
diff --git a/frontend/src/Settings/Tags/Tag.css.d.ts b/frontend/src/Settings/Tags/Tag.css.d.ts
new file mode 100644
index 000000000..51d18383e
--- /dev/null
+++ b/frontend/src/Settings/Tags/Tag.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'label': string;
+ 'tag': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Tags/Tag.js b/frontend/src/Settings/Tags/Tag.js
new file mode 100644
index 000000000..525bf5844
--- /dev/null
+++ b/frontend/src/Settings/Tags/Tag.js
@@ -0,0 +1,208 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Card from 'Components/Card';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import { kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import TagDetailsModal from './Details/TagDetailsModal';
+import TagInUse from './TagInUse';
+import styles from './Tag.css';
+
+class Tag extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false,
+ isDeleteTagModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onShowDetailsPress = () => {
+ this.setState({ isDetailsModalOpen: true });
+ };
+
+ onDetailsModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ };
+
+ onDeleteTagPress = () => {
+ this.setState({
+ isDetailsModalOpen: false,
+ isDeleteTagModalOpen: true
+ });
+ };
+
+ onDeleteTagModalClose= () => {
+ this.setState({ isDeleteTagModalOpen: false });
+ };
+
+ onConfirmDeleteTag = () => {
+ this.props.onConfirmDeleteTag({ id: this.props.id });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ label,
+ delayProfileIds,
+ importListIds,
+ notificationIds,
+ restrictionIds,
+ indexerIds,
+ downloadClientIds,
+ autoTagIds,
+ artistIds
+ } = this.props;
+
+ const {
+ isDetailsModalOpen,
+ isDeleteTagModalOpen
+ } = this.state;
+
+ const isTagUsed = !!(
+ delayProfileIds.length ||
+ importListIds.length ||
+ notificationIds.length ||
+ restrictionIds.length ||
+ indexerIds.length ||
+ downloadClientIds.length ||
+ autoTagIds.length ||
+ artistIds.length
+ );
+
+ return (
+
+
+ {label}
+
+
+ {
+ isTagUsed ?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
:
+ null
+ }
+
+ {
+ !isTagUsed &&
+
+ No links
+
+ }
+
+
+
+
+
+ );
+ }
+}
+
+Tag.propTypes = {
+ id: PropTypes.number.isRequired,
+ label: PropTypes.string.isRequired,
+ delayProfileIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ importListIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ notificationIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ restrictionIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ indexerIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ downloadClientIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ autoTagIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ artistIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ onConfirmDeleteTag: PropTypes.func.isRequired
+};
+
+Tag.defaultProps = {
+ delayProfileIds: [],
+ importListIds: [],
+ notificationIds: [],
+ restrictionIds: [],
+ indexerIds: [],
+ downloadClientIds: [],
+ autoTagIds: [],
+ artistIds: []
+};
+
+export default Tag;
diff --git a/frontend/src/Settings/Tags/TagConnector.js b/frontend/src/Settings/Tags/TagConnector.js
new file mode 100644
index 000000000..986acc8e8
--- /dev/null
+++ b/frontend/src/Settings/Tags/TagConnector.js
@@ -0,0 +1,22 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { deleteTag } from 'Store/Actions/tagActions';
+import createTagDetailsSelector from 'Store/Selectors/createTagDetailsSelector';
+import Tag from './Tag';
+
+function createMapStateToProps() {
+ return createSelector(
+ createTagDetailsSelector(),
+ (tagDetails) => {
+ return {
+ ...tagDetails
+ };
+ }
+ );
+}
+
+const mapStateToProps = {
+ onConfirmDeleteTag: deleteTag
+};
+
+export default connect(createMapStateToProps, mapStateToProps)(Tag);
diff --git a/frontend/src/Settings/Tags/TagInUse.js b/frontend/src/Settings/Tags/TagInUse.js
new file mode 100644
index 000000000..27228fa2e
--- /dev/null
+++ b/frontend/src/Settings/Tags/TagInUse.js
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+export default function TagInUse(props) {
+ const {
+ label,
+ labelPlural,
+ count
+ } = props;
+
+ if (count === 0) {
+ return null;
+ }
+
+ if (count > 1 && labelPlural) {
+ return (
+
+ {count} {labelPlural.toLowerCase()}
+
+ );
+ }
+
+ return (
+
+ {count} {label.toLowerCase()}
+
+ );
+}
+
+TagInUse.propTypes = {
+ label: PropTypes.string.isRequired,
+ labelPlural: PropTypes.string,
+ count: PropTypes.number.isRequired
+};
diff --git a/frontend/src/Settings/Tags/TagSettings.js b/frontend/src/Settings/Tags/TagSettings.js
new file mode 100644
index 000000000..ca8672603
--- /dev/null
+++ b/frontend/src/Settings/Tags/TagSettings.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import translate from 'Utilities/String/translate';
+import AutoTaggings from './AutoTagging/AutoTaggings';
+import TagsConnector from './TagsConnector';
+
+function TagSettings() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+export default TagSettings;
diff --git a/frontend/src/Settings/Tags/Tags.css b/frontend/src/Settings/Tags/Tags.css
new file mode 100644
index 000000000..5a44f8331
--- /dev/null
+++ b/frontend/src/Settings/Tags/Tags.css
@@ -0,0 +1,4 @@
+.tags {
+ display: flex;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Settings/Tags/Tags.css.d.ts b/frontend/src/Settings/Tags/Tags.css.d.ts
new file mode 100644
index 000000000..bf5da21eb
--- /dev/null
+++ b/frontend/src/Settings/Tags/Tags.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'tags': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Tags/Tags.js b/frontend/src/Settings/Tags/Tags.js
new file mode 100644
index 000000000..c5f3d8706
--- /dev/null
+++ b/frontend/src/Settings/Tags/Tags.js
@@ -0,0 +1,54 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Alert from 'Components/Alert';
+import FieldSet from 'Components/FieldSet';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import { kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import TagConnector from './TagConnector';
+import styles from './Tags.css';
+
+function Tags(props) {
+ const {
+ items,
+ ...otherProps
+ } = props;
+
+ if (!items.length) {
+ return (
+
+ {translate('NoTagsHaveBeenAddedYet')}
+
+ );
+ }
+
+ return (
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ );
+}
+
+Tags.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default Tags;
diff --git a/frontend/src/Settings/Tags/TagsConnector.js b/frontend/src/Settings/Tags/TagsConnector.js
new file mode 100644
index 000000000..15f31d3c5
--- /dev/null
+++ b/frontend/src/Settings/Tags/TagsConnector.js
@@ -0,0 +1,90 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchDelayProfiles, fetchDownloadClients, fetchImportLists, fetchIndexers, fetchNotifications, fetchReleaseProfiles } from 'Store/Actions/settingsActions';
+import { fetchTagDetails, fetchTags } from 'Store/Actions/tagActions';
+import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
+import sortByProp from 'Utilities/Array/sortByProp';
+import Tags from './Tags';
+
+function createMapStateToProps() {
+ return createSelector(
+ createSortedSectionSelector('tags', sortByProp('label')),
+ (tags) => {
+ const isFetching = tags.isFetching || tags.details.isFetching;
+ const error = tags.error || tags.details.error;
+ const isPopulated = tags.isPopulated && tags.details.isPopulated;
+
+ return {
+ ...tags,
+ isFetching,
+ error,
+ isPopulated
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchTags: fetchTags,
+ dispatchFetchTagDetails: fetchTagDetails,
+ dispatchFetchDelayProfiles: fetchDelayProfiles,
+ dispatchFetchImportLists: fetchImportLists,
+ dispatchFetchNotifications: fetchNotifications,
+ dispatchFetchReleaseProfiles: fetchReleaseProfiles,
+ dispatchFetchIndexers: fetchIndexers,
+ dispatchFetchDownloadClients: fetchDownloadClients
+};
+
+class MetadatasConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ dispatchFetchTags,
+ dispatchFetchTagDetails,
+ dispatchFetchDelayProfiles,
+ dispatchFetchImportLists,
+ dispatchFetchNotifications,
+ dispatchFetchReleaseProfiles,
+ dispatchFetchIndexers,
+ dispatchFetchDownloadClients
+ } = this.props;
+
+ dispatchFetchTags();
+ dispatchFetchTagDetails();
+ dispatchFetchDelayProfiles();
+ dispatchFetchImportLists();
+ dispatchFetchNotifications();
+ dispatchFetchReleaseProfiles();
+ dispatchFetchIndexers();
+ dispatchFetchDownloadClients();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MetadatasConnector.propTypes = {
+ dispatchFetchTags: PropTypes.func.isRequired,
+ dispatchFetchTagDetails: PropTypes.func.isRequired,
+ dispatchFetchDelayProfiles: PropTypes.func.isRequired,
+ dispatchFetchImportLists: PropTypes.func.isRequired,
+ dispatchFetchNotifications: PropTypes.func.isRequired,
+ dispatchFetchReleaseProfiles: PropTypes.func.isRequired,
+ dispatchFetchIndexers: PropTypes.func.isRequired,
+ dispatchFetchDownloadClients: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector);
diff --git a/frontend/src/Settings/UI/UISettings.css b/frontend/src/Settings/UI/UISettings.css
new file mode 100644
index 000000000..2e6213823
--- /dev/null
+++ b/frontend/src/Settings/UI/UISettings.css
@@ -0,0 +1,3 @@
+.columnGroup {
+ flex-direction: column;
+}
diff --git a/frontend/src/Settings/UI/UISettings.css.d.ts b/frontend/src/Settings/UI/UISettings.css.d.ts
new file mode 100644
index 000000000..ef0f392ed
--- /dev/null
+++ b/frontend/src/Settings/UI/UISettings.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'columnGroup': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/UI/UISettings.js b/frontend/src/Settings/UI/UISettings.js
new file mode 100644
index 000000000..cc27829df
--- /dev/null
+++ b/frontend/src/Settings/UI/UISettings.js
@@ -0,0 +1,300 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Alert from 'Components/Alert';
+import FieldSet from 'Components/FieldSet';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import { inputTypes, kinds } from 'Helpers/Props';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import themes from 'Styles/Themes';
+import titleCase from 'Utilities/String/titleCase';
+import translate from 'Utilities/String/translate';
+import styles from './UISettings.css';
+
+export const firstDayOfWeekOptions = [
+ { key: 0, value: 'Sunday' },
+ { key: 1, value: 'Monday' }
+];
+
+export const weekColumnOptions = [
+ { key: 'ddd M/D', value: 'Tue 3/25', hint: 'ddd M/D' },
+ { key: 'ddd MM/DD', value: 'Tue 03/25', hint: 'ddd MM/DD' },
+ { key: 'ddd D/M', value: 'Tue 25/3', hint: 'ddd D/M' },
+ { key: 'ddd DD/MM', value: 'Tue 25/03', hint: 'ddd DD/MM' }
+];
+
+const shortDateFormatOptions = [
+ { key: 'MMM D YYYY', value: 'Mar 25 2014', hint: 'MMM D YYYY' },
+ { key: 'DD MMM YYYY', value: '25 Mar 2014', hint: 'DD MMM YYYY' },
+ { key: 'MM/D/YYYY', value: '03/25/2014', hint: 'MM/D/YYYY' },
+ { key: 'MM/DD/YYYY', value: '03/25/2014', hint: 'MM/DD/YYYY' },
+ { key: 'DD/MM/YYYY', value: '25/03/2014', hint: 'DD/MM/YYYY' },
+ { key: 'YYYY-MM-DD', value: '2014-03-25', hint: 'YYYY-MM-DD' }
+];
+
+const longDateFormatOptions = [
+ { key: 'dddd, MMMM D YYYY', value: 'Tuesday, March 25, 2014' },
+ { key: 'dddd, D MMMM YYYY', value: 'Tuesday, 25 March, 2014' }
+];
+
+export const timeFormatOptions = [
+ { key: 'h(:mm)a', value: '5pm/5:30pm' },
+ { key: 'HH:mm', value: '17:00/17:30' }
+];
+
+class UISettings extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ onInputChange,
+ onSavePress,
+ languages,
+ ...otherProps
+ } = this.props;
+
+ const uiLanguages = languages.filter((item) => item.value !== 'Original');
+ const themeOptions = Object.keys(themes)
+ .map((theme) => ({ key: theme, value: titleCase(theme) }));
+
+ return (
+
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+
+ {translate('UnableToLoadUISettings')}
+
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+ }
+
+
+ );
+ }
+
+}
+
+UISettings.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ languages: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default UISettings;
diff --git a/frontend/src/Settings/UI/UISettingsConnector.js b/frontend/src/Settings/UI/UISettingsConnector.js
new file mode 100644
index 000000000..ea9194f8c
--- /dev/null
+++ b/frontend/src/Settings/UI/UISettingsConnector.js
@@ -0,0 +1,102 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import { fetchUISettings, saveUISettings, setUISettingsValue } from 'Store/Actions/settingsActions';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import UISettings from './UISettings';
+
+const SECTION = 'ui';
+
+function createLanguagesSelector() {
+ return createSelector(
+ (state) => state.settings.languages,
+ (languages) => {
+ const items = languages.items;
+ const filterItems = ['Any', 'Unknown'];
+
+ if (!items) {
+ return [];
+ }
+
+ const newItems = items.filter((lang) => !filterItems.includes(lang.name)).map((item) => {
+ return {
+ key: item.id,
+ value: item.name
+ };
+ });
+
+ return newItems;
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createSettingsSectionSelector(SECTION),
+ createLanguagesSelector(),
+ (advancedSettings, sectionSettings, languages) => {
+ return {
+ advancedSettings,
+ languages,
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setUISettingsValue,
+ saveUISettings,
+ fetchUISettings,
+ clearPendingChanges
+};
+
+class UISettingsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchUISettings();
+ }
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: `settings.${SECTION}` });
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setUISettingsValue({ name, value });
+ };
+
+ onSavePress = () => {
+ this.props.saveUISettings();
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+UISettingsConnector.propTypes = {
+ setUISettingsValue: PropTypes.func.isRequired,
+ saveUISettings: PropTypes.func.isRequired,
+ fetchUISettings: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(UISettingsConnector);
diff --git a/frontend/src/Shared/piwikCheck.js b/frontend/src/Shared/piwikCheck.js
new file mode 100644
index 000000000..9fcc84361
--- /dev/null
+++ b/frontend/src/Shared/piwikCheck.js
@@ -0,0 +1,10 @@
+if (window.Lidarr.analytics) {
+ const d = document;
+ const g = d.createElement('script');
+ const s = d.getElementsByTagName('script')[0];
+ g.type = 'text/javascript';
+ g.async = true;
+ g.defer = true;
+ g.src = '//piwik.sonarr.tv/piwik.js';
+ s.parentNode.insertBefore(g, s);
+}
diff --git a/frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js
new file mode 100644
index 000000000..2952973a9
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js
@@ -0,0 +1,12 @@
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createClearReducer(section, defaultState) {
+ return (state) => {
+ const newState = Object.assign(getSectionState(state, section), defaultState);
+
+ return updateSectionState(state, section, newState);
+ };
+}
+
+export default createClearReducer;
diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js
new file mode 100644
index 000000000..d58bb1cd4
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js
@@ -0,0 +1,14 @@
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createSetClientSideCollectionFilterReducer(section) {
+ return (state, { payload }) => {
+ const newState = getSectionState(state, section);
+
+ newState.selectedFilterKey = payload.selectedFilterKey;
+
+ return updateSectionState(state, section, newState);
+ };
+}
+
+export default createSetClientSideCollectionFilterReducer;
diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js
new file mode 100644
index 000000000..1bc048a80
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js
@@ -0,0 +1,29 @@
+import { sortDirections } from 'Helpers/Props';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createSetClientSideCollectionSortReducer(section) {
+ return (state, { payload }) => {
+ const newState = getSectionState(state, section);
+
+ const sortKey = payload.sortKey || newState.sortKey;
+ let sortDirection = payload.sortDirection;
+
+ if (!sortDirection) {
+ if (payload.sortKey === newState.sortKey) {
+ sortDirection = newState.sortDirection === sortDirections.ASCENDING ?
+ sortDirections.DESCENDING :
+ sortDirections.ASCENDING;
+ } else {
+ sortDirection = newState.sortDirection;
+ }
+ }
+
+ newState.sortKey = sortKey;
+ newState.sortDirection = sortDirection;
+
+ return updateSectionState(state, section, newState);
+ };
+}
+
+export default createSetClientSideCollectionSortReducer;
diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js
new file mode 100644
index 000000000..3af58dd3b
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js
@@ -0,0 +1,23 @@
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createSetProviderFieldValueReducer(section) {
+ return (state, { payload }) => {
+ if (section === payload.section) {
+ const { name, value } = payload;
+ const newState = getSectionState(state, section);
+ newState.pendingChanges = Object.assign({}, newState.pendingChanges);
+ const fields = Object.assign({}, newState.pendingChanges.fields || {});
+
+ fields[name] = value;
+
+ newState.pendingChanges.fields = fields;
+
+ return updateSectionState(state, section, newState);
+ }
+
+ return state;
+ };
+}
+
+export default createSetProviderFieldValueReducer;
diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js
new file mode 100644
index 000000000..474eb7bb2
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js
@@ -0,0 +1,36 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createSetSettingValueReducer(section) {
+ return (state, { payload }) => {
+ if (section === payload.section) {
+ const { name, value } = payload;
+ const newState = getSectionState(state, section);
+ newState.pendingChanges = Object.assign({}, newState.pendingChanges);
+
+ const currentValue = newState.item ? newState.item[name] : null;
+ const pendingState = newState.pendingChanges;
+
+ let parsedValue = null;
+
+ if (_.isNumber(currentValue) && value != null) {
+ parsedValue = parseInt(value);
+ } else {
+ parsedValue = value;
+ }
+
+ if (currentValue === parsedValue) {
+ delete pendingState[name];
+ } else {
+ pendingState[name] = parsedValue;
+ }
+
+ return updateSectionState(state, section, newState);
+ }
+
+ return state;
+ };
+}
+
+export default createSetSettingValueReducer;
diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js
new file mode 100644
index 000000000..70b57446d
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js
@@ -0,0 +1,21 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+const whitelistedProperties = [
+ 'pageSize',
+ 'columns',
+ 'tableOptions'
+];
+
+function createSetTableOptionReducer(section) {
+ return (state, { payload }) => {
+ const newState = Object.assign(
+ getSectionState(state, section),
+ _.pick(payload, whitelistedProperties));
+
+ return updateSectionState(state, section, newState);
+ };
+}
+
+export default createSetTableOptionReducer;
diff --git a/frontend/src/Store/Actions/Creators/createBatchToggleAlbumMonitoredHandler.js b/frontend/src/Store/Actions/Creators/createBatchToggleAlbumMonitoredHandler.js
new file mode 100644
index 000000000..1dab0ab0f
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createBatchToggleAlbumMonitoredHandler.js
@@ -0,0 +1,42 @@
+import updateAlbums from 'Utilities/Album/updateAlbums';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import getSectionState from 'Utilities/State/getSectionState';
+
+function createBatchToggleAlbumMonitoredHandler(section, fetchHandler) {
+ return function(getState, payload, dispatch) {
+ const {
+ albumIds,
+ monitored
+ } = payload;
+
+ const state = getSectionState(getState(), section, true);
+
+ dispatch(updateAlbums(section, state.items, albumIds, {
+ isSaving: true
+ }));
+
+ const promise = createAjaxRequest({
+ url: '/album/monitor',
+ method: 'PUT',
+ data: JSON.stringify({ albumIds, monitored }),
+ dataType: 'json'
+ }).request;
+
+ promise.done(() => {
+ dispatch(updateAlbums(section, state.items, albumIds, {
+ isSaving: false,
+ monitored
+ }));
+
+ dispatch(fetchHandler());
+ });
+
+ promise.fail(() => {
+ dispatch(updateAlbums(section, state.items, albumIds, {
+ isSaving: false
+ }));
+ });
+ };
+}
+
+export default createBatchToggleAlbumMonitoredHandler;
diff --git a/frontend/src/Store/Actions/Creators/createBulkEditItemHandler.js b/frontend/src/Store/Actions/Creators/createBulkEditItemHandler.js
new file mode 100644
index 000000000..f174dae54
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createBulkEditItemHandler.js
@@ -0,0 +1,54 @@
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { set, updateItem } from '../baseActions';
+
+function createBulkEditItemHandler(section, url) {
+ return function(getState, payload, dispatch) {
+
+ dispatch(set({ section, isSaving: true }));
+
+ const ajaxOptions = {
+ url: `${url}`,
+ method: 'PUT',
+ data: JSON.stringify(payload),
+ dataType: 'json'
+ };
+
+ const promise = createAjaxRequest(ajaxOptions).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ set({
+ section,
+ isSaving: false,
+ saveError: null
+ }),
+
+ ...data.map((provider) => {
+
+ const {
+ ...propsToUpdate
+ } = provider;
+
+ return updateItem({
+ id: provider.id,
+ section,
+ ...propsToUpdate
+ });
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+
+ return promise;
+ };
+}
+
+export default createBulkEditItemHandler;
diff --git a/frontend/src/Store/Actions/Creators/createBulkRemoveItemHandler.js b/frontend/src/Store/Actions/Creators/createBulkRemoveItemHandler.js
new file mode 100644
index 000000000..3293ff1b5
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createBulkRemoveItemHandler.js
@@ -0,0 +1,48 @@
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { removeItem, set } from '../baseActions';
+
+function createBulkRemoveItemHandler(section, url) {
+ return function(getState, payload, dispatch) {
+ const {
+ ids
+ } = payload;
+
+ dispatch(set({ section, isDeleting: true }));
+
+ const ajaxOptions = {
+ url: `${url}`,
+ method: 'DELETE',
+ data: JSON.stringify(payload),
+ dataType: 'json'
+ };
+
+ const promise = createAjaxRequest(ajaxOptions).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ set({
+ section,
+ isDeleting: false,
+ deleteError: null
+ }),
+
+ ...ids.map((id) => {
+ return removeItem({ section, id });
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isDeleting: false,
+ deleteError: xhr
+ }));
+ });
+
+ return promise;
+ };
+}
+
+export default createBulkRemoveItemHandler;
diff --git a/frontend/src/Store/Actions/Creators/createFetchHandler.js b/frontend/src/Store/Actions/Creators/createFetchHandler.js
new file mode 100644
index 000000000..c9cd058bd
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createFetchHandler.js
@@ -0,0 +1,44 @@
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { set, update, updateItem } from '../baseActions';
+
+export default function createFetchHandler(section, url) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ const {
+ id,
+ ...otherPayload
+ } = payload;
+
+ const { request, abortRequest } = createAjaxRequest({
+ url: id == null ? url : `${url}/${id}`,
+ data: otherPayload,
+ traditional: true
+ });
+
+ request.done((data) => {
+ dispatch(batchActions([
+ id == null ? update({ section, data }) : updateItem({ section, ...data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ request.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr.aborted ? null : xhr
+ }));
+ });
+
+ return abortRequest;
+ };
+}
diff --git a/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js b/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js
new file mode 100644
index 000000000..a1f24bbbd
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js
@@ -0,0 +1,33 @@
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { set } from '../baseActions';
+
+function createFetchSchemaHandler(section, url) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isSchemaFetching: true }));
+
+ const promise = createAjaxRequest({
+ url
+ }).request;
+
+ promise.done((data) => {
+ dispatch(set({
+ section,
+ isSchemaFetching: false,
+ isSchemaPopulated: true,
+ schemaError: null,
+ schema: data
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSchemaFetching: false,
+ isSchemaPopulated: true,
+ schemaError: xhr
+ }));
+ });
+ };
+}
+
+export default createFetchSchemaHandler;
diff --git a/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js
new file mode 100644
index 000000000..f5ef10a4d
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js
@@ -0,0 +1,73 @@
+import _ from 'lodash';
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
+import getSectionState from 'Utilities/State/getSectionState';
+import { set, updateServerSideCollection } from '../baseActions';
+
+function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter) {
+ const [baseSection] = section.split('.');
+
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ const sectionState = getSectionState(getState(), section, true);
+ const page = payload.page || sectionState.page || 1;
+
+ const data = Object.assign({ page },
+ _.pick(sectionState, [
+ 'pageSize',
+ 'sortDirection',
+ 'sortKey'
+ ]));
+
+ if (fetchDataAugmenter) {
+ fetchDataAugmenter(getState, payload, data);
+ }
+
+ const {
+ selectedFilterKey,
+ filters
+ } = sectionState;
+
+ const customFilters = getState().customFilters.items.filter((customFilter) => {
+ return customFilter.type === section || customFilter.type === baseSection;
+ });
+
+ const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters);
+
+ selectedFilters.forEach((filter) => {
+ data[filter.key] = filter.value;
+ });
+
+ const promise = createAjaxRequest({
+ url,
+ data,
+ traditional: true
+ }).request;
+
+ promise.done((response) => {
+ dispatch(batchActions([
+ updateServerSideCollection({ section, data: response }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ };
+}
+
+export default createFetchServerSideCollectionHandler;
diff --git a/frontend/src/Store/Actions/Creators/createHandleActions.js b/frontend/src/Store/Actions/Creators/createHandleActions.js
new file mode 100644
index 000000000..817cfda24
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createHandleActions.js
@@ -0,0 +1,169 @@
+import _ from 'lodash';
+import { handleActions } from 'redux-actions';
+import {
+ CLEAR_PENDING_CHANGES,
+ REMOVE_ITEM,
+ SET,
+ UPDATE,
+ UPDATE_ITEM,
+ UPDATE_SERVER_SIDE_COLLECTION } from 'Store/Actions/baseActions';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+const omittedProperties = [
+ 'section',
+ 'id'
+];
+
+function createItemMap(data) {
+ return data.reduce((acc, d, index) => {
+ acc[d.id] = index;
+ return acc;
+ }, {});
+}
+
+export default function createHandleActions(handlers, defaultState, section) {
+ return handleActions({
+
+ [SET]: function(state, { payload }) {
+ const payloadSection = payload.section;
+ const [baseSection] = payloadSection.split('.');
+
+ if (section === baseSection) {
+ const newState = Object.assign(getSectionState(state, payloadSection),
+ _.omit(payload, omittedProperties));
+
+ return updateSectionState(state, payloadSection, newState);
+ }
+
+ return state;
+ },
+
+ [UPDATE]: function(state, { payload }) {
+ const payloadSection = payload.section;
+ const [baseSection] = payloadSection.split('.');
+
+ if (section === baseSection) {
+ const newState = getSectionState(state, payloadSection);
+
+ if (_.isArray(payload.data)) {
+ newState.items = payload.data;
+ newState.itemMap = createItemMap(payload.data);
+ } else {
+ newState.item = payload.data;
+ }
+
+ return updateSectionState(state, payloadSection, newState);
+ }
+
+ return state;
+ },
+
+ [UPDATE_ITEM]: function(state, { payload }) {
+ const {
+ section: payloadSection,
+ updateOnly = false,
+ ...otherProps
+ } = payload;
+
+ const [baseSection] = payloadSection.split('.');
+
+ if (section === baseSection) {
+ const newState = getSectionState(state, payloadSection);
+ const items = newState.items;
+
+ // Client side collections that are created by adding items to an
+ // existing array may not have an itemMap, the array is probably empty,
+ // but on the offchance it's not create a new item map based on the
+ // items in the array.
+ const itemMap = newState.itemMap ?? createItemMap(items);
+ const index = payload.id in itemMap ? itemMap[payload.id] : -1;
+
+ newState.items = [...items];
+
+ // TODO: Move adding to it's own reducer
+ if (index >= 0) {
+ const item = items[index];
+ const newItem = { ...item, ...otherProps };
+
+ // if the item to update is equal to existing, then don't actually update
+ // to prevent costly reselections
+ if (_.isEqual(item, newItem)) {
+ return state;
+ }
+
+ newState.items.splice(index, 1, newItem);
+ } else if (!updateOnly) {
+ const newIndex = newState.items.push({ ...otherProps }) - 1;
+
+ newState.itemMap = { ...itemMap };
+ newState.itemMap[payload.id] = newIndex;
+ }
+
+ return updateSectionState(state, payloadSection, newState);
+ }
+
+ return state;
+ },
+
+ [CLEAR_PENDING_CHANGES]: function(state, { payload }) {
+ const payloadSection = payload.section;
+ const [baseSection] = payloadSection.split('.');
+
+ if (section === baseSection) {
+ const newState = getSectionState(state, payloadSection);
+ newState.pendingChanges = {};
+
+ if (newState.hasOwnProperty('saveError')) {
+ newState.saveError = null;
+ }
+
+ return updateSectionState(state, payloadSection, newState);
+ }
+
+ return state;
+ },
+
+ [REMOVE_ITEM]: function(state, { payload }) {
+ const payloadSection = payload.section;
+ const [baseSection] = payloadSection.split('.');
+
+ if (section === baseSection) {
+ const newState = getSectionState(state, payloadSection);
+
+ newState.items = [...newState.items];
+ _.remove(newState.items, { id: payload.id });
+
+ newState.itemMap = createItemMap(newState.items);
+
+ return updateSectionState(state, payloadSection, newState);
+ }
+
+ return state;
+ },
+
+ [UPDATE_SERVER_SIDE_COLLECTION]: function(state, { payload }) {
+ const payloadSection = payload.section;
+ const [baseSection] = payloadSection.split('.');
+
+ if (section === baseSection) {
+ const data = payload.data;
+ const newState = getSectionState(state, payloadSection);
+
+ const serverState = _.omit(data, ['records']);
+ const calculatedState = {
+ totalPages: Math.max(Math.ceil(data.totalRecords / data.pageSize), 1),
+ items: data.records,
+ itemMap: createItemMap(data.records)
+ };
+
+ return updateSectionState(state, payloadSection, Object.assign(newState, serverState, calculatedState));
+ }
+
+ return state;
+ },
+
+ ...handlers
+
+ }, defaultState);
+}
diff --git a/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js
new file mode 100644
index 000000000..3de794bdf
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js
@@ -0,0 +1,46 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { removeItem, set } from '../baseActions';
+
+function createRemoveItemHandler(section, url) {
+ return function(getState, payload, dispatch) {
+ const {
+ id,
+ queryParams
+ } = payload;
+
+ dispatch(set({ section, isDeleting: true }));
+
+ const ajaxOptions = {
+ url: `${url}/${id}?${$.param(queryParams, true)}`,
+ method: 'DELETE'
+ };
+
+ const promise = createAjaxRequest(ajaxOptions).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ set({
+ section,
+ isDeleting: false,
+ deleteError: null
+ }),
+
+ removeItem({ section, id })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isDeleting: false,
+ deleteError: xhr
+ }));
+ });
+
+ return promise;
+ };
+}
+
+export default createRemoveItemHandler;
diff --git a/frontend/src/Store/Actions/Creators/createSaveHandler.js b/frontend/src/Store/Actions/Creators/createSaveHandler.js
new file mode 100644
index 000000000..e064b7e5a
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSaveHandler.js
@@ -0,0 +1,43 @@
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import getSectionState from 'Utilities/State/getSectionState';
+import { set, update } from '../baseActions';
+
+function createSaveHandler(section, url) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isSaving: true }));
+
+ const state = getSectionState(getState(), section, true);
+ const saveData = Object.assign({}, state.item, state.pendingChanges, payload);
+
+ const promise = createAjaxRequest({
+ url,
+ method: 'PUT',
+ dataType: 'json',
+ data: JSON.stringify(saveData)
+ }).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ };
+}
+
+export default createSaveHandler;
diff --git a/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js
new file mode 100644
index 000000000..800895d77
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js
@@ -0,0 +1,87 @@
+import $ from 'jquery';
+import _ from 'lodash';
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import getProviderState from 'Utilities/State/getProviderState';
+import { removeItem, set, updateItem } from '../baseActions';
+
+const abortCurrentRequests = {};
+let lastSaveData = null;
+
+export function createCancelSaveProviderHandler(section) {
+ return function(getState, payload, dispatch) {
+ if (abortCurrentRequests[section]) {
+ abortCurrentRequests[section]();
+ abortCurrentRequests[section] = null;
+ }
+ };
+}
+
+function createSaveProviderHandler(section, url, options = {}, removeStale = false) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isSaving: true }));
+
+ const {
+ id,
+ queryParams = {},
+ ...otherPayload
+ } = payload;
+
+ const saveData = Array.isArray(id) ? id.map((x) => getProviderState({ id: x, ...otherPayload }, getState, section)) : getProviderState({ id, ...otherPayload }, getState, section);
+ const requestUrl = id ? `${url}/${id}` : url;
+ const params = { ...queryParams };
+
+ // If the user is re-saving the same provider without changes
+ // force it to be saved.
+
+ if (_.isEqual(saveData, lastSaveData)) {
+ params.forceSave = true;
+ }
+
+ lastSaveData = saveData;
+
+ const ajaxOptions = {
+ url: `${requestUrl}?${$.param(params, true)}`,
+ method: id ? 'PUT' : 'POST',
+ contentType: 'application/json',
+ dataType: 'json',
+ data: JSON.stringify(saveData)
+ };
+
+ const { request, abortRequest } = createAjaxRequest(ajaxOptions);
+
+ abortCurrentRequests[section] = abortRequest;
+
+ request.done((data) => {
+ lastSaveData = null;
+
+ if (!Array.isArray(data)) {
+ data = [data];
+ }
+
+ const toRemove = removeStale && Array.isArray(id) ? _.difference(id, _.map(data, 'id')) : [];
+
+ dispatch(batchActions(
+ data.map((item) => updateItem({ section, ...item })).concat(
+ toRemove.map((item) => removeItem({ section, id: item }))
+ ).concat(
+ set({
+ section,
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ })
+ )));
+ });
+
+ request.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr.aborted ? null : xhr
+ }));
+ });
+ };
+}
+
+export default createSaveProviderHandler;
diff --git a/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js b/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js
new file mode 100644
index 000000000..8b4697377
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js
@@ -0,0 +1,52 @@
+import pages from 'Utilities/pages';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import createFetchServerSideCollectionHandler from './createFetchServerSideCollectionHandler';
+import createSetServerSideCollectionFilterHandler from './createSetServerSideCollectionFilterHandler';
+import createSetServerSideCollectionPageHandler from './createSetServerSideCollectionPageHandler';
+import createSetServerSideCollectionSortHandler from './createSetServerSideCollectionSortHandler';
+
+function createServerSideCollectionHandlers(section, url, fetchThunk, handlers, fetchDataAugmenter) {
+ const actionHandlers = {};
+ const fetchHandlerType = handlers[serverSideCollectionHandlers.FETCH];
+ const fetchHandler = createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter);
+ actionHandlers[fetchHandlerType] = fetchHandler;
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.FIRST_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.FIRST_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.FIRST, fetchThunk);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.PREVIOUS_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.PREVIOUS_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.PREVIOUS, fetchThunk);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.NEXT_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.NEXT_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.NEXT, fetchThunk);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.LAST_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.LAST_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.LAST, fetchThunk);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.EXACT_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.EXACT_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.EXACT, fetchThunk);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.SORT)) {
+ const handlerType = handlers[serverSideCollectionHandlers.SORT];
+ actionHandlers[handlerType] = createSetServerSideCollectionSortHandler(section, fetchThunk);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.FILTER)) {
+ const handlerType = handlers[serverSideCollectionHandlers.FILTER];
+ actionHandlers[handlerType] = createSetServerSideCollectionFilterHandler(section, fetchThunk);
+ }
+
+ return actionHandlers;
+}
+
+export default createServerSideCollectionHandlers;
diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js
new file mode 100644
index 000000000..d7e476444
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js
@@ -0,0 +1,10 @@
+import { set } from '../baseActions';
+
+function createSetServerSideCollectionFilterHandler(section, fetchHandler) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, ...payload }));
+ dispatch(fetchHandler({ page: 1 }));
+ };
+}
+
+export default createSetServerSideCollectionFilterHandler;
diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js
new file mode 100644
index 000000000..12b21bb0d
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js
@@ -0,0 +1,35 @@
+import pages from 'Utilities/pages';
+import getSectionState from 'Utilities/State/getSectionState';
+
+function createSetServerSideCollectionPageHandler(section, page, fetchHandler) {
+ return function(getState, payload, dispatch) {
+ const sectionState = getSectionState(getState(), section, true);
+ const currentPage = sectionState.page || 1;
+ let nextPage = 0;
+
+ switch (page) {
+ case pages.FIRST:
+ nextPage = 1;
+ break;
+ case pages.PREVIOUS:
+ nextPage = currentPage - 1;
+ break;
+ case pages.NEXT:
+ nextPage = currentPage + 1;
+ break;
+ case pages.LAST:
+ nextPage = sectionState.totalPages;
+ break;
+ default:
+ nextPage = payload.page;
+ }
+
+ // If we prefer to update the page immediately we should
+ // set the page and not pass a page to the fetch handler.
+
+ // dispatch(set({ section, page: nextPage }));
+ dispatch(fetchHandler({ page: nextPage }));
+ };
+}
+
+export default createSetServerSideCollectionPageHandler;
diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js
new file mode 100644
index 000000000..9bddfc2b7
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js
@@ -0,0 +1,26 @@
+import { sortDirections } from 'Helpers/Props';
+import getSectionState from 'Utilities/State/getSectionState';
+import { set } from '../baseActions';
+
+function createSetServerSideCollectionSortHandler(section, fetchHandler) {
+ return function(getState, payload, dispatch) {
+ const sectionState = getSectionState(getState(), section, true);
+ const sortKey = payload.sortKey || sectionState.sortKey;
+ let sortDirection = payload.sortDirection;
+
+ if (!sortDirection) {
+ if (payload.sortKey === sectionState.sortKey) {
+ sortDirection = sectionState.sortDirection === sortDirections.ASCENDING ?
+ sortDirections.DESCENDING :
+ sortDirections.ASCENDING;
+ } else {
+ sortDirection = sectionState.sortDirection;
+ }
+ }
+
+ dispatch(set({ section, sortKey, sortDirection }));
+ dispatch(fetchHandler());
+ };
+}
+
+export default createSetServerSideCollectionSortHandler;
diff --git a/frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js b/frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js
new file mode 100644
index 000000000..77deaec64
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js
@@ -0,0 +1,34 @@
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { set } from '../baseActions';
+
+function createTestAllProvidersHandler(section, url) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isTestingAll: true }));
+
+ const ajaxOptions = {
+ url: `${url}/testall`,
+ method: 'POST',
+ contentType: 'application/json',
+ dataType: 'json'
+ };
+
+ const { request } = createAjaxRequest(ajaxOptions);
+
+ request.done((data) => {
+ dispatch(set({
+ section,
+ isTestingAll: false,
+ saveError: null
+ }));
+ });
+
+ request.fail((xhr) => {
+ dispatch(set({
+ section,
+ isTestingAll: false
+ }));
+ });
+ };
+}
+
+export default createTestAllProvidersHandler;
diff --git a/frontend/src/Store/Actions/Creators/createTestProviderHandler.js b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js
new file mode 100644
index 000000000..e35157dbd
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js
@@ -0,0 +1,72 @@
+import $ from 'jquery';
+import _ from 'lodash';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import getProviderState from 'Utilities/State/getProviderState';
+import { set } from '../baseActions';
+
+const abortCurrentRequests = {};
+let lastTestData = null;
+
+export function createCancelTestProviderHandler(section) {
+ return function(getState, payload, dispatch) {
+ if (abortCurrentRequests[section]) {
+ abortCurrentRequests[section]();
+ abortCurrentRequests[section] = null;
+ }
+ };
+}
+
+function createTestProviderHandler(section, url) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isTesting: true }));
+
+ const {
+ queryParams = {},
+ ...otherPayload
+ } = payload;
+
+ const testData = getProviderState({ ...otherPayload }, getState, section);
+ const params = { ...queryParams };
+
+ // If the user is re-testing the same provider without changes
+ // force it to be tested.
+
+ if (_.isEqual(testData, lastTestData)) {
+ params.forceTest = true;
+ }
+
+ lastTestData = testData;
+
+ const ajaxOptions = {
+ url: `${url}/test?${$.param(params, true)}`,
+ method: 'POST',
+ contentType: 'application/json',
+ dataType: 'json',
+ data: JSON.stringify(testData)
+ };
+
+ const { request, abortRequest } = createAjaxRequest(ajaxOptions);
+
+ abortCurrentRequests[section] = abortRequest;
+
+ request.done((data) => {
+ lastTestData = null;
+
+ dispatch(set({
+ section,
+ isTesting: false,
+ saveError: null
+ }));
+ });
+
+ request.fail((xhr) => {
+ dispatch(set({
+ section,
+ isTesting: false,
+ saveError: xhr.aborted ? null : xhr
+ }));
+ });
+ };
+}
+
+export default createTestProviderHandler;
diff --git a/frontend/src/Store/Actions/Settings/autoTaggingSpecifications.js b/frontend/src/Store/Actions/Settings/autoTaggingSpecifications.js
new file mode 100644
index 000000000..cfc919c7d
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/autoTaggingSpecifications.js
@@ -0,0 +1,193 @@
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createClearReducer from 'Store/Actions/Creators/Reducers/createClearReducer';
+import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+import getNextId from 'Utilities/State/getNextId';
+import getProviderState from 'Utilities/State/getProviderState';
+import getSectionState from 'Utilities/State/getSectionState';
+import selectProviderSchema from 'Utilities/State/selectProviderSchema';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import { removeItem, set, update, updateItem } from '../baseActions';
+
+//
+// Variables
+
+const section = 'settings.autoTaggingSpecifications';
+
+//
+// Actions Types
+
+export const FETCH_AUTO_TAGGING_SPECIFICATIONS = 'settings/autoTaggingSpecifications/fetchAutoTaggingSpecifications';
+export const FETCH_AUTO_TAGGING_SPECIFICATION_SCHEMA = 'settings/autoTaggingSpecifications/fetchAutoTaggingSpecificationSchema';
+export const SELECT_AUTO_TAGGING_SPECIFICATION_SCHEMA = 'settings/autoTaggingSpecifications/selectAutoTaggingSpecificationSchema';
+export const SET_AUTO_TAGGING_SPECIFICATION_VALUE = 'settings/autoTaggingSpecifications/setAutoTaggingSpecificationValue';
+export const SET_AUTO_TAGGING_SPECIFICATION_FIELD_VALUE = 'settings/autoTaggingSpecifications/setAutoTaggingSpecificationFieldValue';
+export const SAVE_AUTO_TAGGING_SPECIFICATION = 'settings/autoTaggingSpecifications/saveAutoTaggingSpecification';
+export const DELETE_AUTO_TAGGING_SPECIFICATION = 'settings/autoTaggingSpecifications/deleteAutoTaggingSpecification';
+export const DELETE_ALL_AUTO_TAGGING_SPECIFICATION = 'settings/autoTaggingSpecifications/deleteAllAutoTaggingSpecification';
+export const CLONE_AUTO_TAGGING_SPECIFICATION = 'settings/autoTaggingSpecifications/cloneAutoTaggingSpecification';
+export const CLEAR_AUTO_TAGGING_SPECIFICATIONS = 'settings/autoTaggingSpecifications/clearAutoTaggingSpecifications';
+export const CLEAR_AUTO_TAGGING_SPECIFICATION_PENDING = 'settings/autoTaggingSpecifications/clearAutoTaggingSpecificationPending';
+//
+// Action Creators
+
+export const fetchAutoTaggingSpecifications = createThunk(FETCH_AUTO_TAGGING_SPECIFICATIONS);
+export const fetchAutoTaggingSpecificationSchema = createThunk(FETCH_AUTO_TAGGING_SPECIFICATION_SCHEMA);
+export const selectAutoTaggingSpecificationSchema = createAction(SELECT_AUTO_TAGGING_SPECIFICATION_SCHEMA);
+
+export const saveAutoTaggingSpecification = createThunk(SAVE_AUTO_TAGGING_SPECIFICATION);
+export const deleteAutoTaggingSpecification = createThunk(DELETE_AUTO_TAGGING_SPECIFICATION);
+export const deleteAllAutoTaggingSpecification = createThunk(DELETE_ALL_AUTO_TAGGING_SPECIFICATION);
+
+export const setAutoTaggingSpecificationValue = createAction(SET_AUTO_TAGGING_SPECIFICATION_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const setAutoTaggingSpecificationFieldValue = createAction(SET_AUTO_TAGGING_SPECIFICATION_FIELD_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const cloneAutoTaggingSpecification = createAction(CLONE_AUTO_TAGGING_SPECIFICATION);
+
+export const clearAutoTaggingSpecification = createAction(CLEAR_AUTO_TAGGING_SPECIFICATIONS);
+
+export const clearAutoTaggingSpecificationPending = createThunk(CLEAR_AUTO_TAGGING_SPECIFICATION_PENDING);
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isPopulated: false,
+ error: null,
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ schemaError: null,
+ schema: [],
+ selectedSchema: {},
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_AUTO_TAGGING_SPECIFICATION_SCHEMA]: createFetchSchemaHandler(section, '/autoTagging/schema'),
+
+ [FETCH_AUTO_TAGGING_SPECIFICATIONS]: (getState, payload, dispatch) => {
+ let tags = [];
+ if (payload.id) {
+ const cfState = getSectionState(getState(), 'settings.autoTaggings', true);
+ const cf = cfState.items[cfState.itemMap[payload.id]];
+ tags = cf.specifications.map((tag, i) => {
+ return {
+ id: i + 1,
+ ...tag
+ };
+ });
+ }
+
+ dispatch(batchActions([
+ update({ section, data: tags }),
+ set({
+ section,
+ isPopulated: true
+ })
+ ]));
+ },
+
+ [SAVE_AUTO_TAGGING_SPECIFICATION]: (getState, payload, dispatch) => {
+ const {
+ id,
+ ...otherPayload
+ } = payload;
+
+ const saveData = getProviderState({ id, ...otherPayload }, getState, section, false);
+
+ // we have to set id since not actually posting to server yet
+ if (!saveData.id) {
+ saveData.id = getNextId(getState().settings.autoTaggingSpecifications.items);
+ }
+
+ dispatch(batchActions([
+ updateItem({ section, ...saveData }),
+ set({
+ section,
+ pendingChanges: {}
+ })
+ ]));
+ },
+
+ [DELETE_AUTO_TAGGING_SPECIFICATION]: (getState, payload, dispatch) => {
+ const id = payload.id;
+ return dispatch(removeItem({ section, id }));
+ },
+
+ [DELETE_ALL_AUTO_TAGGING_SPECIFICATION]: (getState, payload, dispatch) => {
+ return dispatch(set({
+ section,
+ items: []
+ }));
+ },
+
+ [CLEAR_AUTO_TAGGING_SPECIFICATION_PENDING]: (getState, payload, dispatch) => {
+ return dispatch(set({
+ section,
+ pendingChanges: {}
+ }));
+ }
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_AUTO_TAGGING_SPECIFICATION_VALUE]: createSetSettingValueReducer(section),
+ [SET_AUTO_TAGGING_SPECIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
+
+ [SELECT_AUTO_TAGGING_SPECIFICATION_SCHEMA]: (state, { payload }) => {
+ return selectProviderSchema(state, section, payload, (selectedSchema) => {
+ return selectedSchema;
+ });
+ },
+
+ [CLONE_AUTO_TAGGING_SPECIFICATION]: function(state, { payload }) {
+ const id = payload.id;
+ const newState = getSectionState(state, section);
+ const items = newState.items;
+ const item = items.find((i) => i.id === id);
+ const newId = getNextId(newState.items);
+ const newItem = {
+ ...item,
+ id: newId,
+ name: `${item.name} - Copy`
+ };
+ newState.items = [...items, newItem];
+ newState.itemMap[newId] = newState.items.length - 1;
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [CLEAR_AUTO_TAGGING_SPECIFICATIONS]: createClearReducer(section, {
+ isPopulated: false,
+ error: null,
+ items: []
+ })
+ }
+};
diff --git a/frontend/src/Store/Actions/Settings/autoTaggings.js b/frontend/src/Store/Actions/Settings/autoTaggings.js
new file mode 100644
index 000000000..35b3d4149
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/autoTaggings.js
@@ -0,0 +1,109 @@
+import { createAction } from 'redux-actions';
+import { set } from 'Store/Actions/baseActions';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+//
+// Variables
+
+const section = 'settings.autoTaggings';
+
+//
+// Actions Types
+
+export const FETCH_AUTO_TAGGINGS = 'settings/autoTaggings/fetchAutoTaggings';
+export const SAVE_AUTO_TAGGING = 'settings/autoTaggings/saveAutoTagging';
+export const DELETE_AUTO_TAGGING = 'settings/autoTaggings/deleteAutoTagging';
+export const SET_AUTO_TAGGING_VALUE = 'settings/autoTaggings/setAutoTaggingValue';
+export const CLONE_AUTO_TAGGING = 'settings/autoTaggings/cloneAutoTagging';
+
+//
+// Action Creators
+
+export const fetchAutoTaggings = createThunk(FETCH_AUTO_TAGGINGS);
+export const saveAutoTagging = createThunk(SAVE_AUTO_TAGGING);
+export const deleteAutoTagging = createThunk(DELETE_AUTO_TAGGING);
+
+export const setAutoTaggingValue = createAction(SET_AUTO_TAGGING_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const cloneAutoTagging = createAction(CLONE_AUTO_TAGGING);
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ isFetching: false,
+ isPopulated: false,
+ schema: {
+ removeTagsAutomatically: false,
+ tags: []
+ },
+ error: null,
+ isDeleting: false,
+ deleteError: null,
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_AUTO_TAGGINGS]: createFetchHandler(section, '/autoTagging'),
+
+ [DELETE_AUTO_TAGGING]: createRemoveItemHandler(section, '/autoTagging'),
+
+ [SAVE_AUTO_TAGGING]: (getState, payload, dispatch) => {
+ // move the format tags in as a pending change
+ const state = getState();
+ const pendingChanges = state.settings.autoTaggings.pendingChanges;
+ pendingChanges.specifications = state.settings.autoTaggingSpecifications.items;
+ dispatch(set({
+ section,
+ pendingChanges
+ }));
+
+ createSaveProviderHandler(section, '/autoTagging')(getState, payload, dispatch);
+ }
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_AUTO_TAGGING_VALUE]: createSetSettingValueReducer(section),
+
+ [CLONE_AUTO_TAGGING]: function(state, { payload }) {
+ const id = payload.id;
+ const newState = getSectionState(state, section);
+ const item = newState.items.find((i) => i.id === id);
+ const pendingChanges = { ...item, id: 0 };
+ delete pendingChanges.id;
+
+ pendingChanges.name = `${pendingChanges.name} - Copy`;
+ newState.pendingChanges = pendingChanges;
+
+ return updateSectionState(state, section, newState);
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/customFormatSpecifications.js b/frontend/src/Store/Actions/Settings/customFormatSpecifications.js
new file mode 100644
index 000000000..d74898d3e
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/customFormatSpecifications.js
@@ -0,0 +1,193 @@
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createClearReducer from 'Store/Actions/Creators/Reducers/createClearReducer';
+import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+import getNextId from 'Utilities/State/getNextId';
+import getProviderState from 'Utilities/State/getProviderState';
+import getSectionState from 'Utilities/State/getSectionState';
+import selectProviderSchema from 'Utilities/State/selectProviderSchema';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import { removeItem, set, update, updateItem } from '../baseActions';
+
+//
+// Variables
+
+const section = 'settings.customFormatSpecifications';
+
+//
+// Actions Types
+
+export const FETCH_CUSTOM_FORMAT_SPECIFICATIONS = 'settings/customFormatSpecifications/fetchCustomFormatSpecifications';
+export const FETCH_CUSTOM_FORMAT_SPECIFICATION_SCHEMA = 'settings/customFormatSpecifications/fetchCustomFormatSpecificationSchema';
+export const SELECT_CUSTOM_FORMAT_SPECIFICATION_SCHEMA = 'settings/customFormatSpecifications/selectCustomFormatSpecificationSchema';
+export const SET_CUSTOM_FORMAT_SPECIFICATION_VALUE = 'settings/customFormatSpecifications/setCustomFormatSpecificationValue';
+export const SET_CUSTOM_FORMAT_SPECIFICATION_FIELD_VALUE = 'settings/customFormatSpecifications/setCustomFormatSpecificationFieldValue';
+export const SAVE_CUSTOM_FORMAT_SPECIFICATION = 'settings/customFormatSpecifications/saveCustomFormatSpecification';
+export const DELETE_CUSTOM_FORMAT_SPECIFICATION = 'settings/customFormatSpecifications/deleteCustomFormatSpecification';
+export const DELETE_ALL_CUSTOM_FORMAT_SPECIFICATION = 'settings/customFormatSpecifications/deleteAllCustomFormatSpecification';
+export const CLONE_CUSTOM_FORMAT_SPECIFICATION = 'settings/customFormatSpecifications/cloneCustomFormatSpecification';
+export const CLEAR_CUSTOM_FORMAT_SPECIFICATIONS = 'settings/customFormatSpecifications/clearCustomFormatSpecifications';
+export const CLEAR_CUSTOM_FORMAT_SPECIFICATION_PENDING = 'settings/customFormatSpecifications/clearCustomFormatSpecificationPending';
+//
+// Action Creators
+
+export const fetchCustomFormatSpecifications = createThunk(FETCH_CUSTOM_FORMAT_SPECIFICATIONS);
+export const fetchCustomFormatSpecificationSchema = createThunk(FETCH_CUSTOM_FORMAT_SPECIFICATION_SCHEMA);
+export const selectCustomFormatSpecificationSchema = createAction(SELECT_CUSTOM_FORMAT_SPECIFICATION_SCHEMA);
+
+export const saveCustomFormatSpecification = createThunk(SAVE_CUSTOM_FORMAT_SPECIFICATION);
+export const deleteCustomFormatSpecification = createThunk(DELETE_CUSTOM_FORMAT_SPECIFICATION);
+export const deleteAllCustomFormatSpecification = createThunk(DELETE_ALL_CUSTOM_FORMAT_SPECIFICATION);
+
+export const setCustomFormatSpecificationValue = createAction(SET_CUSTOM_FORMAT_SPECIFICATION_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const setCustomFormatSpecificationFieldValue = createAction(SET_CUSTOM_FORMAT_SPECIFICATION_FIELD_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const cloneCustomFormatSpecification = createAction(CLONE_CUSTOM_FORMAT_SPECIFICATION);
+
+export const clearCustomFormatSpecification = createAction(CLEAR_CUSTOM_FORMAT_SPECIFICATIONS);
+
+export const clearCustomFormatSpecificationPending = createThunk(CLEAR_CUSTOM_FORMAT_SPECIFICATION_PENDING);
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isPopulated: false,
+ error: null,
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ schemaError: null,
+ schema: [],
+ selectedSchema: {},
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_CUSTOM_FORMAT_SPECIFICATION_SCHEMA]: createFetchSchemaHandler(section, '/customformat/schema'),
+
+ [FETCH_CUSTOM_FORMAT_SPECIFICATIONS]: (getState, payload, dispatch) => {
+ let tags = [];
+ if (payload.id) {
+ const cfState = getSectionState(getState(), 'settings.customFormats', true);
+ const cf = cfState.items[cfState.itemMap[payload.id]];
+ tags = cf.specifications.map((tag, i) => {
+ return {
+ id: i + 1,
+ ...tag
+ };
+ });
+ }
+
+ dispatch(batchActions([
+ update({ section, data: tags }),
+ set({
+ section,
+ isPopulated: true
+ })
+ ]));
+ },
+
+ [SAVE_CUSTOM_FORMAT_SPECIFICATION]: (getState, payload, dispatch) => {
+ const {
+ id,
+ ...otherPayload
+ } = payload;
+
+ const saveData = getProviderState({ id, ...otherPayload }, getState, section, false);
+
+ // we have to set id since not actually posting to server yet
+ if (!saveData.id) {
+ saveData.id = getNextId(getState().settings.customFormatSpecifications.items);
+ }
+
+ dispatch(batchActions([
+ updateItem({ section, ...saveData }),
+ set({
+ section,
+ pendingChanges: {}
+ })
+ ]));
+ },
+
+ [DELETE_CUSTOM_FORMAT_SPECIFICATION]: (getState, payload, dispatch) => {
+ const id = payload.id;
+ return dispatch(removeItem({ section, id }));
+ },
+
+ [DELETE_ALL_CUSTOM_FORMAT_SPECIFICATION]: (getState, payload, dispatch) => {
+ return dispatch(set({
+ section,
+ items: []
+ }));
+ },
+
+ [CLEAR_CUSTOM_FORMAT_SPECIFICATION_PENDING]: (getState, payload, dispatch) => {
+ return dispatch(set({
+ section,
+ pendingChanges: {}
+ }));
+ }
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_CUSTOM_FORMAT_SPECIFICATION_VALUE]: createSetSettingValueReducer(section),
+ [SET_CUSTOM_FORMAT_SPECIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
+
+ [SELECT_CUSTOM_FORMAT_SPECIFICATION_SCHEMA]: (state, { payload }) => {
+ return selectProviderSchema(state, section, payload, (selectedSchema) => {
+ return selectedSchema;
+ });
+ },
+
+ [CLONE_CUSTOM_FORMAT_SPECIFICATION]: function(state, { payload }) {
+ const id = payload.id;
+ const newState = getSectionState(state, section);
+ const items = newState.items;
+ const item = items.find((i) => i.id === id);
+ const newId = getNextId(newState.items);
+ const newItem = {
+ ...item,
+ id: newId,
+ name: `${item.name} - Copy`
+ };
+ newState.items = [...items, newItem];
+ newState.itemMap[newId] = newState.items.length - 1;
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [CLEAR_CUSTOM_FORMAT_SPECIFICATIONS]: createClearReducer(section, {
+ isPopulated: false,
+ error: null,
+ items: []
+ })
+ }
+};
diff --git a/frontend/src/Store/Actions/Settings/customFormats.js b/frontend/src/Store/Actions/Settings/customFormats.js
new file mode 100644
index 000000000..3b8a209f9
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/customFormats.js
@@ -0,0 +1,134 @@
+import { createAction } from 'redux-actions';
+import { sortDirections } from 'Helpers/Props';
+import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
+import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+import createSetClientSideCollectionSortReducer
+ from 'Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import { set } from '../baseActions';
+
+//
+// Variables
+
+const section = 'settings.customFormats';
+
+//
+// Actions Types
+
+export const FETCH_CUSTOM_FORMATS = 'settings/customFormats/fetchCustomFormats';
+export const SAVE_CUSTOM_FORMAT = 'settings/customFormats/saveCustomFormat';
+export const DELETE_CUSTOM_FORMAT = 'settings/customFormats/deleteCustomFormat';
+export const SET_CUSTOM_FORMAT_VALUE = 'settings/customFormats/setCustomFormatValue';
+export const CLONE_CUSTOM_FORMAT = 'settings/customFormats/cloneCustomFormat';
+export const BULK_EDIT_CUSTOM_FORMATS = 'settings/downloadClients/bulkEditCustomFormats';
+export const BULK_DELETE_CUSTOM_FORMATS = 'settings/downloadClients/bulkDeleteCustomFormats';
+export const SET_MANAGE_CUSTOM_FORMATS_SORT = 'settings/downloadClients/setManageCustomFormatsSort';
+
+//
+// Action Creators
+
+export const fetchCustomFormats = createThunk(FETCH_CUSTOM_FORMATS);
+export const saveCustomFormat = createThunk(SAVE_CUSTOM_FORMAT);
+export const deleteCustomFormat = createThunk(DELETE_CUSTOM_FORMAT);
+export const bulkEditCustomFormats = createThunk(BULK_EDIT_CUSTOM_FORMATS);
+export const bulkDeleteCustomFormats = createThunk(BULK_DELETE_CUSTOM_FORMATS);
+export const setManageCustomFormatsSort = createAction(SET_MANAGE_CUSTOM_FORMATS_SORT);
+
+export const setCustomFormatValue = createAction(SET_CUSTOM_FORMAT_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const cloneCustomFormat = createAction(CLONE_CUSTOM_FORMAT);
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ isDeleting: false,
+ deleteError: null,
+ items: [],
+ pendingChanges: {},
+
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ schemaError: null,
+ schema: {
+ includeCustomFormatWhenRenaming: false
+ },
+
+ sortKey: 'name',
+ sortDirection: sortDirections.ASCENDING,
+ sortPredicates: {
+ name: ({ name }) => {
+ return name.toLocaleLowerCase();
+ }
+ }
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_CUSTOM_FORMATS]: createFetchHandler(section, '/customformat'),
+
+ [DELETE_CUSTOM_FORMAT]: createRemoveItemHandler(section, '/customformat'),
+
+ [SAVE_CUSTOM_FORMAT]: (getState, payload, dispatch) => {
+ // move the format tags in as a pending change
+ const state = getState();
+ const pendingChanges = state.settings.customFormats.pendingChanges;
+ pendingChanges.specifications = state.settings.customFormatSpecifications.items;
+ dispatch(set({
+ section,
+ pendingChanges
+ }));
+
+ createSaveProviderHandler(section, '/customformat')(getState, payload, dispatch);
+ },
+
+ [BULK_EDIT_CUSTOM_FORMATS]: createBulkEditItemHandler(section, '/customformat/bulk'),
+ [BULK_DELETE_CUSTOM_FORMATS]: createBulkRemoveItemHandler(section, '/customformat/bulk')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_CUSTOM_FORMAT_VALUE]: createSetSettingValueReducer(section),
+
+ [CLONE_CUSTOM_FORMAT]: function(state, { payload }) {
+ const id = payload.id;
+ const newState = getSectionState(state, section);
+ const item = newState.items.find((i) => i.id === id);
+ const pendingChanges = { ...item, id: 0 };
+ delete pendingChanges.id;
+
+ pendingChanges.name = `${pendingChanges.name} - Copy`;
+ newState.pendingChanges = pendingChanges;
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [SET_MANAGE_CUSTOM_FORMATS_SORT]: createSetClientSideCollectionSortReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/delayProfiles.js b/frontend/src/Store/Actions/Settings/delayProfiles.js
new file mode 100644
index 000000000..38abc5841
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/delayProfiles.js
@@ -0,0 +1,103 @@
+import _ from 'lodash';
+import { createAction } from 'redux-actions';
+import { update } from 'Store/Actions/baseActions';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+
+//
+// Variables
+
+const section = 'settings.delayProfiles';
+
+//
+// Actions Types
+
+export const FETCH_DELAY_PROFILES = 'settings/delayProfiles/fetchDelayProfiles';
+export const FETCH_DELAY_PROFILE_SCHEMA = 'settings/delayProfiles/fetchDelayProfileSchema';
+export const SAVE_DELAY_PROFILE = 'settings/delayProfiles/saveDelayProfile';
+export const DELETE_DELAY_PROFILE = 'settings/delayProfiles/deleteDelayProfile';
+export const REORDER_DELAY_PROFILE = 'settings/delayProfiles/reorderDelayProfile';
+export const SET_DELAY_PROFILE_VALUE = 'settings/delayProfiles/setDelayProfileValue';
+
+//
+// Action Creators
+
+export const fetchDelayProfiles = createThunk(FETCH_DELAY_PROFILES);
+export const fetchDelayProfileSchema = createThunk(FETCH_DELAY_PROFILE_SCHEMA);
+export const saveDelayProfile = createThunk(SAVE_DELAY_PROFILE);
+export const deleteDelayProfile = createThunk(DELETE_DELAY_PROFILE);
+export const reorderDelayProfile = createThunk(REORDER_DELAY_PROFILE);
+
+export const setDelayProfileValue = createAction(SET_DELAY_PROFILE_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_DELAY_PROFILES]: createFetchHandler(section, '/delayprofile'),
+ [FETCH_DELAY_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/delayprofile/schema'),
+
+ [SAVE_DELAY_PROFILE]: createSaveProviderHandler(section, '/delayprofile'),
+ [DELETE_DELAY_PROFILE]: createRemoveItemHandler(section, '/delayprofile'),
+
+ [REORDER_DELAY_PROFILE]: (getState, payload, dispatch) => {
+ const { id, moveIndex } = payload;
+ const moveOrder = moveIndex + 1;
+ const delayProfiles = getState().settings.delayProfiles.items;
+ const moving = _.find(delayProfiles, { id });
+
+ // Don't move if the order hasn't changed
+ if (moving.order === moveOrder) {
+ return;
+ }
+
+ const after = moveIndex > 0 ? _.find(delayProfiles, { order: moveIndex }) : null;
+ const afterQueryParam = after ? `after=${after.id}` : '';
+
+ const promise = createAjaxRequest({
+ method: 'PUT',
+ url: `/delayprofile/reorder/${id}?${afterQueryParam}`
+ }).request;
+
+ promise.done((data) => {
+ dispatch(update({ section, data }));
+ });
+ }
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_DELAY_PROFILE_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/downloadClientOptions.js b/frontend/src/Store/Actions/Settings/downloadClientOptions.js
new file mode 100644
index 000000000..0cd7b285e
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/downloadClientOptions.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+
+//
+// Variables
+
+const section = 'settings.downloadClientOptions';
+
+//
+// Actions Types
+
+export const FETCH_DOWNLOAD_CLIENT_OPTIONS = 'FETCH_DOWNLOAD_CLIENT_OPTIONS';
+export const SET_DOWNLOAD_CLIENT_OPTIONS_VALUE = 'SET_DOWNLOAD_CLIENT_OPTIONS_VALUE';
+export const SAVE_DOWNLOAD_CLIENT_OPTIONS = 'SAVE_DOWNLOAD_CLIENT_OPTIONS';
+
+//
+// Action Creators
+
+export const fetchDownloadClientOptions = createThunk(FETCH_DOWNLOAD_CLIENT_OPTIONS);
+export const saveDownloadClientOptions = createThunk(SAVE_DOWNLOAD_CLIENT_OPTIONS);
+export const setDownloadClientOptionsValue = createAction(SET_DOWNLOAD_CLIENT_OPTIONS_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_DOWNLOAD_CLIENT_OPTIONS]: createFetchHandler(section, '/config/downloadclient'),
+ [SAVE_DOWNLOAD_CLIENT_OPTIONS]: createSaveHandler(section, '/config/downloadclient')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_DOWNLOAD_CLIENT_OPTIONS_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/downloadClients.js b/frontend/src/Store/Actions/Settings/downloadClients.js
new file mode 100644
index 000000000..1113e7daf
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/downloadClients.js
@@ -0,0 +1,142 @@
+import { createAction } from 'redux-actions';
+import { sortDirections } from 'Helpers/Props';
+import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
+import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
+import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler';
+import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
+import createSetClientSideCollectionSortReducer from 'Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer';
+import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+import selectProviderSchema from 'Utilities/State/selectProviderSchema';
+
+//
+// Variables
+
+const section = 'settings.downloadClients';
+
+//
+// Actions Types
+
+export const FETCH_DOWNLOAD_CLIENTS = 'settings/downloadClients/fetchDownloadClients';
+export const FETCH_DOWNLOAD_CLIENT_SCHEMA = 'settings/downloadClients/fetchDownloadClientSchema';
+export const SELECT_DOWNLOAD_CLIENT_SCHEMA = 'settings/downloadClients/selectDownloadClientSchema';
+export const SET_DOWNLOAD_CLIENT_VALUE = 'settings/downloadClients/setDownloadClientValue';
+export const SET_DOWNLOAD_CLIENT_FIELD_VALUE = 'settings/downloadClients/setDownloadClientFieldValue';
+export const SAVE_DOWNLOAD_CLIENT = 'settings/downloadClients/saveDownloadClient';
+export const CANCEL_SAVE_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelSaveDownloadClient';
+export const DELETE_DOWNLOAD_CLIENT = 'settings/downloadClients/deleteDownloadClient';
+export const TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/testDownloadClient';
+export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestDownloadClient';
+export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients';
+export const BULK_EDIT_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkEditDownloadClients';
+export const BULK_DELETE_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkDeleteDownloadClients';
+export const SET_MANAGE_DOWNLOAD_CLIENTS_SORT = 'settings/downloadClients/setManageDownloadClientsSort';
+
+//
+// Action Creators
+
+export const fetchDownloadClients = createThunk(FETCH_DOWNLOAD_CLIENTS);
+export const fetchDownloadClientSchema = createThunk(FETCH_DOWNLOAD_CLIENT_SCHEMA);
+export const selectDownloadClientSchema = createAction(SELECT_DOWNLOAD_CLIENT_SCHEMA);
+
+export const saveDownloadClient = createThunk(SAVE_DOWNLOAD_CLIENT);
+export const cancelSaveDownloadClient = createThunk(CANCEL_SAVE_DOWNLOAD_CLIENT);
+export const deleteDownloadClient = createThunk(DELETE_DOWNLOAD_CLIENT);
+export const testDownloadClient = createThunk(TEST_DOWNLOAD_CLIENT);
+export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT);
+export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS);
+export const bulkEditDownloadClients = createThunk(BULK_EDIT_DOWNLOAD_CLIENTS);
+export const bulkDeleteDownloadClients = createThunk(BULK_DELETE_DOWNLOAD_CLIENTS);
+export const setManageDownloadClientsSort = createAction(SET_MANAGE_DOWNLOAD_CLIENTS_SORT);
+
+export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const setDownloadClientFieldValue = createAction(SET_DOWNLOAD_CLIENT_FIELD_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ schemaError: null,
+ schema: [],
+ selectedSchema: {},
+ isSaving: false,
+ saveError: null,
+ isDeleting: false,
+ deleteError: null,
+ isTesting: false,
+ isTestingAll: false,
+ items: [],
+ pendingChanges: {},
+ sortKey: 'name',
+ sortDirection: sortDirections.ASCENDING,
+ sortPredicates: {
+ name: ({ name }) => {
+ return name.toLocaleLowerCase();
+ }
+ }
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_DOWNLOAD_CLIENTS]: createFetchHandler(section, '/downloadclient'),
+ [FETCH_DOWNLOAD_CLIENT_SCHEMA]: createFetchSchemaHandler(section, '/downloadclient/schema'),
+
+ [SAVE_DOWNLOAD_CLIENT]: createSaveProviderHandler(section, '/downloadclient'),
+ [CANCEL_SAVE_DOWNLOAD_CLIENT]: createCancelSaveProviderHandler(section),
+ [DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler(section, '/downloadclient'),
+ [TEST_DOWNLOAD_CLIENT]: createTestProviderHandler(section, '/downloadclient'),
+ [CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section),
+ [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient'),
+ [BULK_EDIT_DOWNLOAD_CLIENTS]: createBulkEditItemHandler(section, '/downloadclient/bulk'),
+ [BULK_DELETE_DOWNLOAD_CLIENTS]: createBulkRemoveItemHandler(section, '/downloadclient/bulk')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_DOWNLOAD_CLIENT_VALUE]: createSetSettingValueReducer(section),
+ [SET_DOWNLOAD_CLIENT_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
+
+ [SELECT_DOWNLOAD_CLIENT_SCHEMA]: (state, { payload }) => {
+ return selectProviderSchema(state, section, payload, (selectedSchema) => {
+ selectedSchema.name = selectedSchema.implementationName;
+ selectedSchema.enable = true;
+
+ return selectedSchema;
+ });
+ },
+
+ [SET_MANAGE_DOWNLOAD_CLIENTS_SORT]: createSetClientSideCollectionSortReducer(section)
+
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/general.js b/frontend/src/Store/Actions/Settings/general.js
new file mode 100644
index 000000000..98bb2703d
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/general.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+
+//
+// Variables
+
+const section = 'settings.general';
+
+//
+// Actions Types
+
+export const FETCH_GENERAL_SETTINGS = 'settings/general/fetchGeneralSettings';
+export const SET_GENERAL_SETTINGS_VALUE = 'settings/general/setGeneralSettingsValue';
+export const SAVE_GENERAL_SETTINGS = 'settings/general/saveGeneralSettings';
+
+//
+// Action Creators
+
+export const fetchGeneralSettings = createThunk(FETCH_GENERAL_SETTINGS);
+export const saveGeneralSettings = createThunk(SAVE_GENERAL_SETTINGS);
+export const setGeneralSettingsValue = createAction(SET_GENERAL_SETTINGS_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_GENERAL_SETTINGS]: createFetchHandler(section, '/config/host'),
+ [SAVE_GENERAL_SETTINGS]: createSaveHandler(section, '/config/host')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_GENERAL_SETTINGS_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/importListExclusions.js b/frontend/src/Store/Actions/Settings/importListExclusions.js
new file mode 100644
index 000000000..b9b38a0ef
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/importListExclusions.js
@@ -0,0 +1,69 @@
+import { createAction } from 'redux-actions';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+
+//
+// Variables
+
+const section = 'settings.importListExclusions';
+
+//
+// Actions Types
+
+export const FETCH_IMPORT_LIST_EXCLUSIONS = 'settings/importListExclusions/fetchImportListExclusions';
+export const SAVE_IMPORT_LIST_EXCLUSION = 'settings/importListExclusions/saveImportListExclusion';
+export const DELETE_IMPORT_LIST_EXCLUSION = 'settings/importListExclusions/deleteImportListExclusion';
+export const SET_IMPORT_LIST_EXCLUSION_VALUE = 'settings/importListExclusions/setImportListExclusionValue';
+
+//
+// Action Creators
+
+export const fetchImportListExclusions = createThunk(FETCH_IMPORT_LIST_EXCLUSIONS);
+export const saveImportListExclusion = createThunk(SAVE_IMPORT_LIST_EXCLUSION);
+export const deleteImportListExclusion = createThunk(DELETE_IMPORT_LIST_EXCLUSION);
+
+export const setImportListExclusionValue = createAction(SET_IMPORT_LIST_EXCLUSION_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_IMPORT_LIST_EXCLUSIONS]: createFetchHandler(section, '/importlistexclusion'),
+ [SAVE_IMPORT_LIST_EXCLUSION]: createSaveProviderHandler(section, '/importlistexclusion'),
+ [DELETE_IMPORT_LIST_EXCLUSION]: createRemoveItemHandler(section, '/importlistexclusion')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_IMPORT_LIST_EXCLUSION_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/importLists.js b/frontend/src/Store/Actions/Settings/importLists.js
new file mode 100644
index 000000000..fcd6877c4
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/importLists.js
@@ -0,0 +1,132 @@
+import { createAction } from 'redux-actions';
+import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
+import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
+import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler';
+import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
+import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+import selectProviderSchema from 'Utilities/State/selectProviderSchema';
+
+//
+// Variables
+
+const section = 'settings.importLists';
+
+//
+// Actions Types
+
+export const FETCH_IMPORT_LISTS = 'settings/importlists/fetchImportLists';
+export const FETCH_IMPORT_LIST_SCHEMA = 'settings/importlists/fetchImportListSchema';
+export const SELECT_IMPORT_LIST_SCHEMA = 'settings/importlists/selectImportListSchema';
+export const SET_IMPORT_LIST_VALUE = 'settings/importlists/setImportListValue';
+export const SET_IMPORT_LIST_FIELD_VALUE = 'settings/importlists/setImportListFieldValue';
+export const SAVE_IMPORT_LIST = 'settings/importlists/saveImportList';
+export const CANCEL_SAVE_IMPORT_LIST = 'settings/importlists/cancelSaveImportList';
+export const DELETE_IMPORT_LIST = 'settings/importlists/deleteImportList';
+export const TEST_IMPORT_LIST = 'settings/importlists/testImportList';
+export const CANCEL_TEST_IMPORT_LIST = 'settings/importlists/cancelTestImportList';
+export const TEST_ALL_IMPORT_LISTS = 'settings/importlists/testAllImportLists';
+export const BULK_EDIT_IMPORT_LISTS = 'settings/importlists/bulkEditImportLists';
+export const BULK_DELETE_IMPORT_LISTS = 'settings/importlists/bulkDeleteImportLists';
+
+//
+// Action Creators
+
+export const fetchImportLists = createThunk(FETCH_IMPORT_LISTS);
+export const fetchImportListSchema = createThunk(FETCH_IMPORT_LIST_SCHEMA);
+export const selectImportListSchema = createAction(SELECT_IMPORT_LIST_SCHEMA);
+
+export const saveImportList = createThunk(SAVE_IMPORT_LIST);
+export const cancelSaveImportList = createThunk(CANCEL_SAVE_IMPORT_LIST);
+export const deleteImportList = createThunk(DELETE_IMPORT_LIST);
+export const testImportList = createThunk(TEST_IMPORT_LIST);
+export const cancelTestImportList = createThunk(CANCEL_TEST_IMPORT_LIST);
+export const testAllImportLists = createThunk(TEST_ALL_IMPORT_LISTS);
+export const bulkEditImportLists = createThunk(BULK_EDIT_IMPORT_LISTS);
+export const bulkDeleteImportLists = createThunk(BULK_DELETE_IMPORT_LISTS);
+
+export const setImportListValue = createAction(SET_IMPORT_LIST_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const setImportListFieldValue = createAction(SET_IMPORT_LIST_FIELD_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ schemaError: null,
+ schema: [],
+ selectedSchema: {},
+ isSaving: false,
+ saveError: null,
+ isDeleting: false,
+ deleteError: null,
+ isTesting: false,
+ isTestingAll: false,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_IMPORT_LISTS]: createFetchHandler(section, '/importlist'),
+ [FETCH_IMPORT_LIST_SCHEMA]: createFetchSchemaHandler(section, '/importlist/schema'),
+
+ [SAVE_IMPORT_LIST]: createSaveProviderHandler(section, '/importlist'),
+ [CANCEL_SAVE_IMPORT_LIST]: createCancelSaveProviderHandler(section),
+ [DELETE_IMPORT_LIST]: createRemoveItemHandler(section, '/importlist'),
+ [TEST_IMPORT_LIST]: createTestProviderHandler(section, '/importlist'),
+ [CANCEL_TEST_IMPORT_LIST]: createCancelTestProviderHandler(section),
+ [TEST_ALL_IMPORT_LISTS]: createTestAllProvidersHandler(section, '/importlist'),
+ [BULK_EDIT_IMPORT_LISTS]: createBulkEditItemHandler(section, '/importlist/bulk'),
+ [BULK_DELETE_IMPORT_LISTS]: createBulkRemoveItemHandler(section, '/importlist/bulk')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_IMPORT_LIST_VALUE]: createSetSettingValueReducer(section),
+ [SET_IMPORT_LIST_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
+
+ [SELECT_IMPORT_LIST_SCHEMA]: (state, { payload }) => {
+ return selectProviderSchema(state, section, payload, (selectedSchema) => {
+ selectedSchema.name = payload.presetName ?? payload.implementationName;
+ selectedSchema.implementationName = payload.implementationName;
+ selectedSchema.minRefreshInterval = payload.minRefreshInterval;
+ selectedSchema.enableAutomaticAdd = true;
+ selectedSchema.shouldMonitor = 'entireArtist';
+ selectedSchema.rootFolderPath = '';
+
+ return selectedSchema;
+ });
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/indexerFlags.js b/frontend/src/Store/Actions/Settings/indexerFlags.js
new file mode 100644
index 000000000..a53fe1c61
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/indexerFlags.js
@@ -0,0 +1,48 @@
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import { createThunk } from 'Store/thunks';
+
+//
+// Variables
+
+const section = 'settings.indexerFlags';
+
+//
+// Actions Types
+
+export const FETCH_INDEXER_FLAGS = 'settings/indexerFlags/fetchIndexerFlags';
+
+//
+// Action Creators
+
+export const fetchIndexerFlags = createThunk(FETCH_INDEXER_FLAGS);
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_INDEXER_FLAGS]: createFetchHandler(section, '/indexerFlag')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/indexerOptions.js b/frontend/src/Store/Actions/Settings/indexerOptions.js
new file mode 100644
index 000000000..bafc2735d
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/indexerOptions.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+
+//
+// Variables
+
+const section = 'settings.indexerOptions';
+
+//
+// Actions Types
+
+export const FETCH_INDEXER_OPTIONS = 'settings/indexerOptions/fetchIndexerOptions';
+export const SAVE_INDEXER_OPTIONS = 'settings/indexerOptions/saveIndexerOptions';
+export const SET_INDEXER_OPTIONS_VALUE = 'settings/indexerOptions/setIndexerOptionsValue';
+
+//
+// Action Creators
+
+export const fetchIndexerOptions = createThunk(FETCH_INDEXER_OPTIONS);
+export const saveIndexerOptions = createThunk(SAVE_INDEXER_OPTIONS);
+export const setIndexerOptionsValue = createAction(SET_INDEXER_OPTIONS_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_INDEXER_OPTIONS]: createFetchHandler(section, '/config/indexer'),
+ [SAVE_INDEXER_OPTIONS]: createSaveHandler(section, '/config/indexer')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_INDEXER_OPTIONS_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/indexers.js b/frontend/src/Store/Actions/Settings/indexers.js
new file mode 100644
index 000000000..511a2e475
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/indexers.js
@@ -0,0 +1,179 @@
+import { createAction } from 'redux-actions';
+import { sortDirections } from 'Helpers/Props';
+import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
+import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
+import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler';
+import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
+import createSetClientSideCollectionSortReducer from 'Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer';
+import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+import getSectionState from 'Utilities/State/getSectionState';
+import selectProviderSchema from 'Utilities/State/selectProviderSchema';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+//
+// Variables
+
+const section = 'settings.indexers';
+
+//
+// Actions Types
+
+export const FETCH_INDEXERS = 'settings/indexers/fetchIndexers';
+export const FETCH_INDEXER_SCHEMA = 'settings/indexers/fetchIndexerSchema';
+export const SELECT_INDEXER_SCHEMA = 'settings/indexers/selectIndexerSchema';
+export const CLONE_INDEXER = 'settings/indexers/cloneIndexer';
+export const SET_INDEXER_VALUE = 'settings/indexers/setIndexerValue';
+export const SET_INDEXER_FIELD_VALUE = 'settings/indexers/setIndexerFieldValue';
+export const SAVE_INDEXER = 'settings/indexers/saveIndexer';
+export const CANCEL_SAVE_INDEXER = 'settings/indexers/cancelSaveIndexer';
+export const DELETE_INDEXER = 'settings/indexers/deleteIndexer';
+export const TEST_INDEXER = 'settings/indexers/testIndexer';
+export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer';
+export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers';
+export const BULK_EDIT_INDEXERS = 'settings/indexers/bulkEditIndexers';
+export const BULK_DELETE_INDEXERS = 'settings/indexers/bulkDeleteIndexers';
+export const SET_MANAGE_INDEXERS_SORT = 'settings/indexers/setManageIndexersSort';
+
+//
+// Action Creators
+
+export const fetchIndexers = createThunk(FETCH_INDEXERS);
+export const fetchIndexerSchema = createThunk(FETCH_INDEXER_SCHEMA);
+export const selectIndexerSchema = createAction(SELECT_INDEXER_SCHEMA);
+export const cloneIndexer = createAction(CLONE_INDEXER);
+
+export const saveIndexer = createThunk(SAVE_INDEXER);
+export const cancelSaveIndexer = createThunk(CANCEL_SAVE_INDEXER);
+export const deleteIndexer = createThunk(DELETE_INDEXER);
+export const testIndexer = createThunk(TEST_INDEXER);
+export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER);
+export const testAllIndexers = createThunk(TEST_ALL_INDEXERS);
+export const bulkEditIndexers = createThunk(BULK_EDIT_INDEXERS);
+export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS);
+export const setManageIndexersSort = createAction(SET_MANAGE_INDEXERS_SORT);
+
+export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const setIndexerFieldValue = createAction(SET_INDEXER_FIELD_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ schemaError: null,
+ schema: [],
+ selectedSchema: {},
+ isSaving: false,
+ saveError: null,
+ isDeleting: false,
+ deleteError: null,
+ isTesting: false,
+ isTestingAll: false,
+ items: [],
+ pendingChanges: {},
+ sortKey: 'name',
+ sortDirection: sortDirections.ASCENDING,
+ sortPredicates: {
+ name: ({ name }) => {
+ return name.toLocaleLowerCase();
+ }
+ }
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_INDEXERS]: createFetchHandler(section, '/indexer'),
+ [FETCH_INDEXER_SCHEMA]: createFetchSchemaHandler(section, '/indexer/schema'),
+
+ [SAVE_INDEXER]: createSaveProviderHandler(section, '/indexer'),
+ [CANCEL_SAVE_INDEXER]: createCancelSaveProviderHandler(section),
+ [DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'),
+ [TEST_INDEXER]: createTestProviderHandler(section, '/indexer'),
+ [CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section),
+ [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer'),
+ [BULK_EDIT_INDEXERS]: createBulkEditItemHandler(section, '/indexer/bulk'),
+ [BULK_DELETE_INDEXERS]: createBulkRemoveItemHandler(section, '/indexer/bulk')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_INDEXER_VALUE]: createSetSettingValueReducer(section),
+ [SET_INDEXER_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
+
+ [SELECT_INDEXER_SCHEMA]: (state, { payload }) => {
+ return selectProviderSchema(state, section, payload, (selectedSchema) => {
+ selectedSchema.name = payload.presetName ?? payload.implementationName;
+ selectedSchema.implementationName = payload.implementationName;
+ selectedSchema.enableRss = selectedSchema.supportsRss;
+ selectedSchema.enableAutomaticSearch = selectedSchema.supportsSearch;
+ selectedSchema.enableInteractiveSearch = selectedSchema.supportsSearch;
+
+ return selectedSchema;
+ });
+ },
+
+ [CLONE_INDEXER]: function(state, { payload }) {
+ const id = payload.id;
+ const newState = getSectionState(state, section);
+ const item = newState.items.find((i) => i.id === id);
+
+ // Use selectedSchema so `createProviderSettingsSelector` works properly
+ const selectedSchema = { ...item };
+ delete selectedSchema.id;
+ delete selectedSchema.name;
+
+ selectedSchema.fields = selectedSchema.fields.map((field) => {
+ const newField = { ...field };
+
+ if (newField.privacy === 'apiKey' || newField.privacy === 'password') {
+ newField.value = '';
+ }
+
+ return newField;
+ });
+
+ newState.selectedSchema = selectedSchema;
+
+ // Set the name in pendingChanges
+ newState.pendingChanges = {
+ name: `${item.name} - Copy`
+ };
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [SET_MANAGE_INDEXERS_SORT]: createSetClientSideCollectionSortReducer(section)
+
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/languages.js b/frontend/src/Store/Actions/Settings/languages.js
new file mode 100644
index 000000000..a0b62fc49
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/languages.js
@@ -0,0 +1,48 @@
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import { createThunk } from 'Store/thunks';
+
+//
+// Variables
+
+const section = 'settings.languages';
+
+//
+// Actions Types
+
+export const FETCH_LANGUAGES = 'settings/languages/fetchLanguages';
+
+//
+// Action Creators
+
+export const fetchLanguages = createThunk(FETCH_LANGUAGES);
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_LANGUAGES]: createFetchHandler(section, '/language')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/mediaManagement.js b/frontend/src/Store/Actions/Settings/mediaManagement.js
new file mode 100644
index 000000000..b1ace08b0
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/mediaManagement.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+
+//
+// Variables
+
+const section = 'settings.mediaManagement';
+
+//
+// Actions Types
+
+export const FETCH_MEDIA_MANAGEMENT_SETTINGS = 'settings/mediaManagement/fetchMediaManagementSettings';
+export const SAVE_MEDIA_MANAGEMENT_SETTINGS = 'settings/mediaManagement/saveMediaManagementSettings';
+export const SET_MEDIA_MANAGEMENT_SETTINGS_VALUE = 'settings/mediaManagement/setMediaManagementSettingsValue';
+
+//
+// Action Creators
+
+export const fetchMediaManagementSettings = createThunk(FETCH_MEDIA_MANAGEMENT_SETTINGS);
+export const saveMediaManagementSettings = createThunk(SAVE_MEDIA_MANAGEMENT_SETTINGS);
+export const setMediaManagementSettingsValue = createAction(SET_MEDIA_MANAGEMENT_SETTINGS_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_MEDIA_MANAGEMENT_SETTINGS]: createFetchHandler(section, '/config/mediamanagement'),
+ [SAVE_MEDIA_MANAGEMENT_SETTINGS]: createSaveHandler(section, '/config/mediamanagement')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_MEDIA_MANAGEMENT_SETTINGS_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/metadata.js b/frontend/src/Store/Actions/Settings/metadata.js
new file mode 100644
index 000000000..e3a661f6d
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/metadata.js
@@ -0,0 +1,75 @@
+import { createAction } from 'redux-actions';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+
+//
+// Variables
+
+const section = 'settings.metadata';
+
+//
+// Actions Types
+
+export const FETCH_METADATA = 'settings/metadata/fetchMetadata';
+export const SET_METADATA_VALUE = 'settings/metadata/setMetadataValue';
+export const SET_METADATA_FIELD_VALUE = 'settings/metadata/setMetadataFieldValue';
+export const SAVE_METADATA = 'settings/metadata/saveMetadata';
+
+//
+// Action Creators
+
+export const fetchMetadata = createThunk(FETCH_METADATA);
+export const saveMetadata = createThunk(SAVE_METADATA);
+
+export const setMetadataValue = createAction(SET_METADATA_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const setMetadataFieldValue = createAction(SET_METADATA_FIELD_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_METADATA]: createFetchHandler(section, '/metadata'),
+ [SAVE_METADATA]: createSaveProviderHandler(section, '/metadata')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_METADATA_VALUE]: createSetSettingValueReducer(section),
+ [SET_METADATA_FIELD_VALUE]: createSetProviderFieldValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/metadataProfiles.js b/frontend/src/Store/Actions/Settings/metadataProfiles.js
new file mode 100644
index 000000000..2d80cfe91
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/metadataProfiles.js
@@ -0,0 +1,97 @@
+import { createAction } from 'redux-actions';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+//
+// Variables
+
+const section = 'settings.metadataProfiles';
+
+//
+// Actions Types
+
+export const FETCH_METADATA_PROFILES = 'settings/metadataProfiles/fetchMetadataProfiles';
+export const FETCH_METADATA_PROFILE_SCHEMA = 'settings/metadataProfiles/fetchMetadataProfileSchema';
+export const SAVE_METADATA_PROFILE = 'settings/metadataProfiles/saveMetadataProfile';
+export const DELETE_METADATA_PROFILE = 'settings/metadataProfiles/deleteMetadataProfile';
+export const SET_METADATA_PROFILE_VALUE = 'settings/metadataProfiles/setMetadataProfileValue';
+export const CLONE_METADATA_PROFILE = 'settings/metadataProfiles/cloneMetadataProfile';
+
+//
+// Action Creators
+
+export const fetchMetadataProfiles = createThunk(FETCH_METADATA_PROFILES);
+export const fetchMetadataProfileSchema = createThunk(FETCH_METADATA_PROFILE_SCHEMA);
+export const saveMetadataProfile = createThunk(SAVE_METADATA_PROFILE);
+export const deleteMetadataProfile = createThunk(DELETE_METADATA_PROFILE);
+
+export const setMetadataProfileValue = createAction(SET_METADATA_PROFILE_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const cloneMetadataProfile = createAction(CLONE_METADATA_PROFILE);
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isDeleting: false,
+ deleteError: null,
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ schemaError: null,
+ schema: {},
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_METADATA_PROFILES]: createFetchHandler(section, '/metadataprofile'),
+ [FETCH_METADATA_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/metadataprofile/schema'),
+ [SAVE_METADATA_PROFILE]: createSaveProviderHandler(section, '/metadataprofile'),
+ [DELETE_METADATA_PROFILE]: createRemoveItemHandler(section, '/metadataprofile')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_METADATA_PROFILE_VALUE]: createSetSettingValueReducer(section),
+
+ [CLONE_METADATA_PROFILE]: function(state, { payload }) {
+ const id = payload.id;
+ const newState = getSectionState(state, section);
+ const item = newState.items.find((i) => i.id === id);
+ const pendingChanges = { ...item, id: 0 };
+ delete pendingChanges.id;
+
+ pendingChanges.name = `${pendingChanges.name} - Copy`;
+ newState.pendingChanges = pendingChanges;
+
+ return updateSectionState(state, section, newState);
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/metadataProvider.js b/frontend/src/Store/Actions/Settings/metadataProvider.js
new file mode 100644
index 000000000..ba30110ae
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/metadataProvider.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+
+//
+// Variables
+
+const section = 'settings.metadataProvider';
+
+//
+// Actions Types
+
+export const FETCH_METADATA_PROVIDER = 'settings/metadataProvider/fetchMetadataProvider';
+export const SET_METADATA_PROVIDER_VALUE = 'settings/metadataProvider/setMetadataProviderValue';
+export const SAVE_METADATA_PROVIDER = 'settings/metadataProvider/saveMetadataProvider';
+
+//
+// Action Creators
+
+export const fetchMetadataProvider = createThunk(FETCH_METADATA_PROVIDER);
+export const saveMetadataProvider = createThunk(SAVE_METADATA_PROVIDER);
+export const setMetadataProviderValue = createAction(SET_METADATA_PROVIDER_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_METADATA_PROVIDER]: createFetchHandler(section, '/config/metadataProvider'),
+ [SAVE_METADATA_PROVIDER]: createSaveHandler(section, '/config/metadataProvider')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_METADATA_PROVIDER_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/naming.js b/frontend/src/Store/Actions/Settings/naming.js
new file mode 100644
index 000000000..cf5a6818a
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/naming.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+
+//
+// Variables
+
+const section = 'settings.naming';
+
+//
+// Actions Types
+
+export const FETCH_NAMING_SETTINGS = 'settings/naming/fetchNamingSettings';
+export const SAVE_NAMING_SETTINGS = 'settings/naming/saveNamingSettings';
+export const SET_NAMING_SETTINGS_VALUE = 'settings/naming/setNamingSettingsValue';
+
+//
+// Action Creators
+
+export const fetchNamingSettings = createThunk(FETCH_NAMING_SETTINGS);
+export const saveNamingSettings = createThunk(SAVE_NAMING_SETTINGS);
+export const setNamingSettingsValue = createAction(SET_NAMING_SETTINGS_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_NAMING_SETTINGS]: createFetchHandler(section, '/config/naming'),
+ [SAVE_NAMING_SETTINGS]: createSaveHandler(section, '/config/naming')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_NAMING_SETTINGS_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/namingExamples.js b/frontend/src/Store/Actions/Settings/namingExamples.js
new file mode 100644
index 000000000..0fef90ce2
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/namingExamples.js
@@ -0,0 +1,79 @@
+import { batchActions } from 'redux-batched-actions';
+import { set, update } from 'Store/Actions/baseActions';
+import { createThunk } from 'Store/thunks';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+
+//
+// Variables
+
+const section = 'settings.namingExamples';
+
+//
+// Actions Types
+
+export const FETCH_NAMING_EXAMPLES = 'settings/namingExamples/fetchNamingExamples';
+
+//
+// Action Creators
+
+export const fetchNamingExamples = createThunk(FETCH_NAMING_EXAMPLES);
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_NAMING_EXAMPLES]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ const naming = getState().settings.naming;
+
+ const promise = createAjaxRequest({
+ url: '/config/naming/examples',
+ data: Object.assign({}, naming.item, naming.pendingChanges)
+ }).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ }
+ },
+
+ //
+ // Reducers
+
+ reducers: {}
+
+};
diff --git a/frontend/src/Store/Actions/Settings/notifications.js b/frontend/src/Store/Actions/Settings/notifications.js
new file mode 100644
index 000000000..4e557db87
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/notifications.js
@@ -0,0 +1,123 @@
+import { createAction } from 'redux-actions';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
+import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
+import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+import selectProviderSchema from 'Utilities/State/selectProviderSchema';
+
+//
+// Variables
+
+const section = 'settings.notifications';
+
+//
+// Actions Types
+
+export const FETCH_NOTIFICATIONS = 'settings/notifications/fetchNotifications';
+export const FETCH_NOTIFICATION_SCHEMA = 'settings/notifications/fetchNotificationSchema';
+export const SELECT_NOTIFICATION_SCHEMA = 'settings/notifications/selectNotificationSchema';
+export const SET_NOTIFICATION_VALUE = 'settings/notifications/setNotificationValue';
+export const SET_NOTIFICATION_FIELD_VALUE = 'settings/notifications/setNotificationFieldValue';
+export const SAVE_NOTIFICATION = 'settings/notifications/saveNotification';
+export const CANCEL_SAVE_NOTIFICATION = 'settings/notifications/cancelSaveNotification';
+export const DELETE_NOTIFICATION = 'settings/notifications/deleteNotification';
+export const TEST_NOTIFICATION = 'settings/notifications/testNotification';
+export const CANCEL_TEST_NOTIFICATION = 'settings/notifications/cancelTestNotification';
+
+//
+// Action Creators
+
+export const fetchNotifications = createThunk(FETCH_NOTIFICATIONS);
+export const fetchNotificationSchema = createThunk(FETCH_NOTIFICATION_SCHEMA);
+export const selectNotificationSchema = createAction(SELECT_NOTIFICATION_SCHEMA);
+
+export const saveNotification = createThunk(SAVE_NOTIFICATION);
+export const cancelSaveNotification = createThunk(CANCEL_SAVE_NOTIFICATION);
+export const deleteNotification = createThunk(DELETE_NOTIFICATION);
+export const testNotification = createThunk(TEST_NOTIFICATION);
+export const cancelTestNotification = createThunk(CANCEL_TEST_NOTIFICATION);
+
+export const setNotificationValue = createAction(SET_NOTIFICATION_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const setNotificationFieldValue = createAction(SET_NOTIFICATION_FIELD_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ schemaError: null,
+ schema: [],
+ selectedSchema: {},
+ isSaving: false,
+ saveError: null,
+ isTesting: false,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_NOTIFICATIONS]: createFetchHandler(section, '/notification'),
+ [FETCH_NOTIFICATION_SCHEMA]: createFetchSchemaHandler(section, '/notification/schema'),
+
+ [SAVE_NOTIFICATION]: createSaveProviderHandler(section, '/notification'),
+ [CANCEL_SAVE_NOTIFICATION]: createCancelSaveProviderHandler(section),
+ [DELETE_NOTIFICATION]: createRemoveItemHandler(section, '/notification'),
+ [TEST_NOTIFICATION]: createTestProviderHandler(section, '/notification'),
+ [CANCEL_TEST_NOTIFICATION]: createCancelTestProviderHandler(section)
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_NOTIFICATION_VALUE]: createSetSettingValueReducer(section),
+ [SET_NOTIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
+
+ [SELECT_NOTIFICATION_SCHEMA]: (state, { payload }) => {
+ return selectProviderSchema(state, section, payload, (selectedSchema) => {
+ selectedSchema.name = selectedSchema.implementationName;
+ selectedSchema.onGrab = selectedSchema.supportsOnGrab;
+ selectedSchema.onReleaseImport = selectedSchema.supportsOnReleaseImport;
+ selectedSchema.onUpgrade = selectedSchema.supportsOnUpgrade;
+ selectedSchema.onRename = selectedSchema.supportsOnRename;
+ selectedSchema.onArtistAdd = selectedSchema.supportsOnArtistAdd;
+ selectedSchema.onArtistDelete = selectedSchema.supportsOnArtistDelete;
+ selectedSchema.onHealthIssue = selectedSchema.supportsOnHealthIssue;
+ selectedSchema.onDownloadFailure = selectedSchema.supportsOnDownloadFailure;
+ selectedSchema.onImportFailure = selectedSchema.supportsOnImportFailure;
+ selectedSchema.onTrackRetag = selectedSchema.supportsOnTrackRetag;
+ selectedSchema.onApplicationUpdate = selectedSchema.supportsOnApplicationUpdate;
+
+ return selectedSchema;
+ });
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/qualityDefinitions.js b/frontend/src/Store/Actions/Settings/qualityDefinitions.js
new file mode 100644
index 000000000..09317ca07
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/qualityDefinitions.js
@@ -0,0 +1,137 @@
+import _ from 'lodash';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import { clearPendingChanges, set, update } from 'Store/Actions/baseActions';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+import { createThunk } from 'Store/thunks';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+//
+// Variables
+
+const section = 'settings.qualityDefinitions';
+
+//
+// Actions Types
+
+export const FETCH_QUALITY_DEFINITIONS = 'settings/qualityDefinitions/fetchQualityDefinitions';
+export const SAVE_QUALITY_DEFINITIONS = 'settings/qualityDefinitions/saveQualityDefinitions';
+export const SET_QUALITY_DEFINITION_VALUE = 'settings/qualityDefinitions/setQualityDefinitionValue';
+
+//
+// Action Creators
+
+export const fetchQualityDefinitions = createThunk(FETCH_QUALITY_DEFINITIONS);
+export const saveQualityDefinitions = createThunk(SAVE_QUALITY_DEFINITIONS);
+
+export const setQualityDefinitionValue = createAction(SET_QUALITY_DEFINITION_VALUE);
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_QUALITY_DEFINITIONS]: createFetchHandler(section, '/qualitydefinition'),
+ [SAVE_QUALITY_DEFINITIONS]: createSaveHandler(section, '/qualitydefinition'),
+
+ [SAVE_QUALITY_DEFINITIONS]: function(getState, payload, dispatch) {
+ const qualityDefinitions = getState().settings.qualityDefinitions;
+
+ const upatedDefinitions = Object.keys(qualityDefinitions.pendingChanges).map((key) => {
+ const id = parseInt(key);
+ const pendingChanges = qualityDefinitions.pendingChanges[id] || {};
+ const item = _.find(qualityDefinitions.items, { id });
+
+ return Object.assign({}, item, pendingChanges);
+ });
+
+ // If there is nothing to save don't bother isSaving
+ if (!upatedDefinitions || !upatedDefinitions.length) {
+ return;
+ }
+
+ dispatch(set({
+ section,
+ isSaving: true
+ }));
+
+ const promise = createAjaxRequest({
+ method: 'PUT',
+ url: '/qualityDefinition/update',
+ data: JSON.stringify(upatedDefinitions),
+ contentType: 'application/json',
+ dataType: 'json'
+ }).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ set({
+ section,
+ isSaving: false,
+ saveError: null
+ }),
+
+ update({ section, data }),
+ clearPendingChanges({ section })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ }
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_QUALITY_DEFINITION_VALUE]: function(state, { payload }) {
+ const { id, name, value } = payload;
+ const newState = getSectionState(state, section);
+ newState.pendingChanges = _.cloneDeep(newState.pendingChanges);
+
+ const pendingState = newState.pendingChanges[id] || {};
+ const currentValue = _.find(newState.items, { id })[name];
+
+ if (currentValue === value) {
+ delete pendingState[name];
+ } else {
+ pendingState[name] = value;
+ }
+
+ if (_.isEmpty(pendingState)) {
+ delete newState.pendingChanges[id];
+ } else {
+ newState.pendingChanges[id] = pendingState;
+ }
+
+ return updateSectionState(state, section, newState);
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/qualityProfiles.js b/frontend/src/Store/Actions/Settings/qualityProfiles.js
new file mode 100644
index 000000000..8e6036073
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/qualityProfiles.js
@@ -0,0 +1,97 @@
+import { createAction } from 'redux-actions';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+//
+// Variables
+
+const section = 'settings.qualityProfiles';
+
+//
+// Actions Types
+
+export const FETCH_QUALITY_PROFILES = 'settings/qualityProfiles/fetchQualityProfiles';
+export const FETCH_QUALITY_PROFILE_SCHEMA = 'settings/qualityProfiles/fetchQualityProfileSchema';
+export const SAVE_QUALITY_PROFILE = 'settings/qualityProfiles/saveQualityProfile';
+export const DELETE_QUALITY_PROFILE = 'settings/qualityProfiles/deleteQualityProfile';
+export const SET_QUALITY_PROFILE_VALUE = 'settings/qualityProfiles/setQualityProfileValue';
+export const CLONE_QUALITY_PROFILE = 'settings/qualityProfiles/cloneQualityProfile';
+
+//
+// Action Creators
+
+export const fetchQualityProfiles = createThunk(FETCH_QUALITY_PROFILES);
+export const fetchQualityProfileSchema = createThunk(FETCH_QUALITY_PROFILE_SCHEMA);
+export const saveQualityProfile = createThunk(SAVE_QUALITY_PROFILE);
+export const deleteQualityProfile = createThunk(DELETE_QUALITY_PROFILE);
+
+export const setQualityProfileValue = createAction(SET_QUALITY_PROFILE_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const cloneQualityProfile = createAction(CLONE_QUALITY_PROFILE);
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isDeleting: false,
+ deleteError: null,
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ schemaError: null,
+ schema: {},
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_QUALITY_PROFILES]: createFetchHandler(section, '/qualityprofile'),
+ [FETCH_QUALITY_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/qualityprofile/schema'),
+ [SAVE_QUALITY_PROFILE]: createSaveProviderHandler(section, '/qualityprofile'),
+ [DELETE_QUALITY_PROFILE]: createRemoveItemHandler(section, '/qualityprofile')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_QUALITY_PROFILE_VALUE]: createSetSettingValueReducer(section),
+
+ [CLONE_QUALITY_PROFILE]: function(state, { payload }) {
+ const id = payload.id;
+ const newState = getSectionState(state, section);
+ const item = newState.items.find((i) => i.id === id);
+ const pendingChanges = { ...item, id: 0 };
+ delete pendingChanges.id;
+
+ pendingChanges.name = `${pendingChanges.name} - Copy`;
+ newState.pendingChanges = pendingChanges;
+
+ return updateSectionState(state, section, newState);
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/releaseProfiles.js b/frontend/src/Store/Actions/Settings/releaseProfiles.js
new file mode 100644
index 000000000..37fd16c85
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/releaseProfiles.js
@@ -0,0 +1,71 @@
+import { createAction } from 'redux-actions';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+
+//
+// Variables
+
+const section = 'settings.releaseProfiles';
+
+//
+// Actions Types
+
+export const FETCH_RELEASE_PROFILES = 'settings/releaseProfiles/fetchReleaseProfiles';
+export const SAVE_RELEASE_PROFILE = 'settings/releaseProfiles/saveReleaseProfile';
+export const DELETE_RELEASE_PROFILE = 'settings/releaseProfiles/deleteReleaseProfile';
+export const SET_RELEASE_PROFILE_VALUE = 'settings/releaseProfiles/setReleaseProfileValue';
+
+//
+// Action Creators
+
+export const fetchReleaseProfiles = createThunk(FETCH_RELEASE_PROFILES);
+export const saveReleaseProfile = createThunk(SAVE_RELEASE_PROFILE);
+export const deleteReleaseProfile = createThunk(DELETE_RELEASE_PROFILE);
+
+export const setReleaseProfileValue = createAction(SET_RELEASE_PROFILE_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_RELEASE_PROFILES]: createFetchHandler(section, '/releaseprofile'),
+
+ [SAVE_RELEASE_PROFILE]: createSaveProviderHandler(section, '/releaseprofile'),
+
+ [DELETE_RELEASE_PROFILE]: createRemoveItemHandler(section, '/releaseprofile')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_RELEASE_PROFILE_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/remotePathMappings.js b/frontend/src/Store/Actions/Settings/remotePathMappings.js
new file mode 100644
index 000000000..ca7955754
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/remotePathMappings.js
@@ -0,0 +1,69 @@
+import { createAction } from 'redux-actions';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+
+//
+// Variables
+
+const section = 'settings.remotePathMappings';
+
+//
+// Actions Types
+
+export const FETCH_REMOTE_PATH_MAPPINGS = 'settings/remotePathMappings/fetchRemotePathMappings';
+export const SAVE_REMOTE_PATH_MAPPING = 'settings/remotePathMappings/saveRemotePathMapping';
+export const DELETE_REMOTE_PATH_MAPPING = 'settings/remotePathMappings/deleteRemotePathMapping';
+export const SET_REMOTE_PATH_MAPPING_VALUE = 'settings/remotePathMappings/setRemotePathMappingValue';
+
+//
+// Action Creators
+
+export const fetchRemotePathMappings = createThunk(FETCH_REMOTE_PATH_MAPPINGS);
+export const saveRemotePathMapping = createThunk(SAVE_REMOTE_PATH_MAPPING);
+export const deleteRemotePathMapping = createThunk(DELETE_REMOTE_PATH_MAPPING);
+
+export const setRemotePathMappingValue = createAction(SET_REMOTE_PATH_MAPPING_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_REMOTE_PATH_MAPPINGS]: createFetchHandler(section, '/remotepathmapping'),
+ [SAVE_REMOTE_PATH_MAPPING]: createSaveProviderHandler(section, '/remotepathmapping'),
+ [DELETE_REMOTE_PATH_MAPPING]: createRemoveItemHandler(section, '/remotepathmapping')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_REMOTE_PATH_MAPPING_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/rootFolders.js b/frontend/src/Store/Actions/Settings/rootFolders.js
new file mode 100644
index 000000000..42d4b0fce
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/rootFolders.js
@@ -0,0 +1,82 @@
+import { createAction } from 'redux-actions';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+import monitorNewItemsOptions from 'Utilities/Artist/monitorNewItemsOptions';
+import monitorOptions from 'Utilities/Artist/monitorOptions';
+
+//
+// Variables
+
+export const section = 'settings.rootFolders';
+
+//
+// Actions Types
+
+export const FETCH_ROOT_FOLDERS = 'settings/rootFolders/fetchRootFolders';
+export const SET_ROOT_FOLDER_VALUE = 'settings/rootFolders/setRootFolderValue';
+export const SAVE_ROOT_FOLDER = 'settings/rootFolders/saveRootFolder';
+export const CANCEL_SAVE_ROOT_FOLDER = 'settings/rootFolders/cancelSaveRootFolder';
+export const DELETE_ROOT_FOLDER = 'settings/rootFolders/deleteRootFolder';
+
+//
+// Action Creators
+
+export const fetchRootFolders = createThunk(FETCH_ROOT_FOLDERS);
+export const saveRootFolder = createThunk(SAVE_ROOT_FOLDER);
+export const cancelSaveRootFolder = createThunk(CANCEL_SAVE_ROOT_FOLDER);
+export const deleteRootFolder = createThunk(DELETE_ROOT_FOLDER);
+
+export const setRootFolderValue = createAction(SET_ROOT_FOLDER_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ schema: {
+ defaultQualityProfileId: 0,
+ defaultMetadataProfileId: 0,
+ defaultMonitorOption: monitorOptions[0].key,
+ defaultNewItemMonitorOption: monitorNewItemsOptions[0].key,
+ defaultTags: []
+ },
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+
+ [FETCH_ROOT_FOLDERS]: createFetchHandler(section, '/rootFolder'),
+
+ [SAVE_ROOT_FOLDER]: createSaveProviderHandler(section, '/rootFolder'),
+ [CANCEL_SAVE_ROOT_FOLDER]: createCancelSaveProviderHandler(section),
+ [DELETE_ROOT_FOLDER]: createRemoveItemHandler(section, '/rootFolder')
+
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_ROOT_FOLDER_VALUE]: createSetSettingValueReducer(section)
+ }
+};
diff --git a/frontend/src/Store/Actions/Settings/ui.js b/frontend/src/Store/Actions/Settings/ui.js
new file mode 100644
index 000000000..7a57add6d
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/ui.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import { createThunk } from 'Store/thunks';
+
+//
+// Variables
+
+const section = 'settings.ui';
+
+//
+// Actions Types
+
+export const FETCH_UI_SETTINGS = 'settings/ui/fetchUiSettings';
+export const SET_UI_SETTINGS_VALUE = 'SET_UI_SETTINGS_VALUE';
+export const SAVE_UI_SETTINGS = 'SAVE_UI_SETTINGS';
+
+//
+// Action Creators
+
+export const fetchUISettings = createThunk(FETCH_UI_SETTINGS);
+export const saveUISettings = createThunk(SAVE_UI_SETTINGS);
+export const setUISettingsValue = createAction(SET_UI_SETTINGS_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_UI_SETTINGS]: createFetchHandler(section, '/config/ui'),
+ [SAVE_UI_SETTINGS]: createSaveHandler(section, '/config/ui')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_UI_SETTINGS_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/actionTypes.js b/frontend/src/Store/Actions/actionTypes.js
new file mode 100644
index 000000000..dcb13d86d
--- /dev/null
+++ b/frontend/src/Store/Actions/actionTypes.js
@@ -0,0 +1,16 @@
+//
+// App
+
+export const SHOW_MESSAGE = 'SHOW_MESSAGE';
+export const HIDE_MESSAGE = 'HIDE_MESSAGE';
+export const SAVE_DIMENSIONS = 'SAVE_DIMENSIONS';
+export const SET_VERSION = 'SET_VERSION';
+export const SET_APP_VALUE = 'SET_APP_VALUE';
+export const SET_IS_SIDEBAR_VISIBLE = 'SET_IS_SIDEBAR_VISIBLE';
+
+//
+// Settings
+
+export const FETCH_GENERAL_SETTINGS = 'settings/general/fetchGeneralSettings';
+export const SET_GENERAL_SETTINGS_VALUE = 'settings/general/setGeneralSettingsValue';
+export const SAVE_GENERAL_SETTINGS = 'settings/general/saveGeneralSettings';
diff --git a/frontend/src/Store/Actions/albumActions.js b/frontend/src/Store/Actions/albumActions.js
new file mode 100644
index 000000000..4ac39a0aa
--- /dev/null
+++ b/frontend/src/Store/Actions/albumActions.js
@@ -0,0 +1,282 @@
+import _ from 'lodash';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import albumEntities from 'Album/albumEntities';
+import { sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import translate from 'Utilities/String/translate';
+import { updateItem } from './baseActions';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import createSaveProviderHandler from './Creators/createSaveProviderHandler';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+
+//
+// Variables
+
+export const section = 'albums';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ sortKey: 'releaseDate',
+ sortDirection: sortDirections.DESCENDING,
+ items: [],
+ pendingChanges: {},
+ sortPredicates: {
+ rating: function(item) {
+ return item.ratings.value;
+ },
+ size: function(item) {
+ const { statistics = {} } = item;
+
+ return statistics.sizeOnDisk || 0;
+ }
+ },
+
+ columns: [
+ {
+ name: 'monitored',
+ columnLabel: () => translate('Monitored'),
+ isSortable: true,
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'title',
+ label: () => translate('Title'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'releaseDate',
+ label: () => translate('ReleaseDate'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'secondaryTypes',
+ label: () => translate('SecondaryTypes'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'mediumCount',
+ label: () => translate('MediaCount'),
+ isVisible: false
+ },
+ {
+ name: 'trackCount',
+ label: () => translate('TrackCount'),
+ isVisible: false
+ },
+ {
+ name: 'duration',
+ label: () => translate('Duration'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'size',
+ label: () => translate('Size'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'rating',
+ label: () => translate('Rating'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'status',
+ label: () => translate('Status'),
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ columnLabel: () => translate('Actions'),
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+};
+
+export const persistState = [
+ 'albums.sortKey',
+ 'albums.sortDirection',
+ 'albums.columns'
+];
+
+//
+// Actions Types
+
+export const FETCH_ALBUMS = 'albums/fetchAlbums';
+export const SET_ALBUMS_SORT = 'albums/setAlbumsSort';
+export const SET_ALBUMS_TABLE_OPTION = 'albums/setAlbumsTableOption';
+export const CLEAR_ALBUMS = 'albums/clearAlbums';
+export const SET_ALBUM_VALUE = 'albums/setAlbumValue';
+export const SAVE_ALBUM = 'albums/saveAlbum';
+export const DELETE_ALBUM = 'albums/deleteAlbum';
+export const TOGGLE_ALBUM_MONITORED = 'albums/toggleAlbumMonitored';
+export const TOGGLE_ALBUMS_MONITORED = 'albums/toggleAlbumsMonitored';
+
+//
+// Action Creators
+
+export const fetchAlbums = createThunk(FETCH_ALBUMS);
+export const setAlbumsSort = createAction(SET_ALBUMS_SORT);
+export const setAlbumsTableOption = createAction(SET_ALBUMS_TABLE_OPTION);
+export const clearAlbums = createAction(CLEAR_ALBUMS);
+export const toggleAlbumMonitored = createThunk(TOGGLE_ALBUM_MONITORED);
+export const toggleAlbumsMonitored = createThunk(TOGGLE_ALBUMS_MONITORED);
+
+export const saveAlbum = createThunk(SAVE_ALBUM);
+
+export const deleteAlbum = createThunk(DELETE_ALBUM, (payload) => {
+ return {
+ ...payload,
+ queryParams: {
+ deleteFiles: payload.deleteFiles,
+ addImportListExclusion: payload.addImportListExclusion
+ }
+ };
+});
+
+export const setAlbumValue = createAction(SET_ALBUM_VALUE, (payload) => {
+ return {
+ section: 'albums',
+ ...payload
+ };
+});
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_ALBUMS]: createFetchHandler(section, '/album'),
+ [SAVE_ALBUM]: createSaveProviderHandler(section, '/album'),
+ [DELETE_ALBUM]: createRemoveItemHandler(section, '/album'),
+
+ [TOGGLE_ALBUM_MONITORED]: function(getState, payload, dispatch) {
+ const {
+ albumId,
+ albumEntity = albumEntities.ALBUMS,
+ monitored
+ } = payload;
+
+ const albumSection = _.last(albumEntity.split('.'));
+
+ dispatch(updateItem({
+ id: albumId,
+ section: albumSection,
+ isSaving: true
+ }));
+
+ const promise = createAjaxRequest({
+ url: `/album/${albumId}`,
+ method: 'PUT',
+ data: JSON.stringify({ monitored }),
+ dataType: 'json'
+ }).request;
+
+ promise.done((data) => {
+ dispatch(updateItem({
+ id: albumId,
+ section: albumSection,
+ isSaving: false,
+ monitored
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ id: albumId,
+ section: albumSection,
+ isSaving: false
+ }));
+ });
+ },
+
+ [TOGGLE_ALBUMS_MONITORED]: function(getState, payload, dispatch) {
+ const {
+ albumIds,
+ albumEntity = albumEntities.ALBUMS,
+ monitored
+ } = payload;
+
+ dispatch(batchActions(
+ albumIds.map((albumId) => {
+ return updateItem({
+ id: albumId,
+ section: albumEntity,
+ isSaving: true
+ });
+ })
+ ));
+
+ const promise = createAjaxRequest({
+ url: '/album/monitor',
+ method: 'PUT',
+ data: JSON.stringify({ albumIds, monitored }),
+ dataType: 'json'
+ }).request;
+
+ promise.done((data) => {
+ dispatch(batchActions(
+ albumIds.map((albumId) => {
+ return updateItem({
+ id: albumId,
+ section: albumEntity,
+ isSaving: false,
+ monitored
+ });
+ })
+ ));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(batchActions(
+ albumIds.map((albumId) => {
+ return updateItem({
+ id: albumId,
+ section: albumEntity,
+ isSaving: false
+ });
+ })
+ ));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_ALBUMS_SORT]: createSetClientSideCollectionSortReducer(section),
+
+ [SET_ALBUMS_TABLE_OPTION]: createSetTableOptionReducer(section),
+
+ [SET_ALBUM_VALUE]: createSetSettingValueReducer(section),
+
+ [CLEAR_ALBUMS]: (state) => {
+ return Object.assign({}, state, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ });
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/albumHistoryActions.js b/frontend/src/Store/Actions/albumHistoryActions.js
new file mode 100644
index 000000000..12cb1e4c0
--- /dev/null
+++ b/frontend/src/Store/Actions/albumHistoryActions.js
@@ -0,0 +1,112 @@
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import { sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { set, update } from './baseActions';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'albumHistory';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+};
+
+//
+// Actions Types
+
+export const FETCH_ALBUM_HISTORY = 'albumHistory/fetchAlbumHistory';
+export const CLEAR_ALBUM_HISTORY = 'albumHistory/clearAlbumHistory';
+export const ALBUM_HISTORY_MARK_AS_FAILED = 'albumHistory/albumHistoryMarkAsFailed';
+
+//
+// Action Creators
+
+export const fetchAlbumHistory = createThunk(FETCH_ALBUM_HISTORY);
+export const clearAlbumHistory = createAction(CLEAR_ALBUM_HISTORY);
+export const albumHistoryMarkAsFailed = createThunk(ALBUM_HISTORY_MARK_AS_FAILED);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_ALBUM_HISTORY]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ const queryParams = {
+ pageSize: 1000,
+ page: 1,
+ sortKey: 'date',
+ sortDirection: sortDirections.DESCENDING,
+ albumId: payload.albumId
+ };
+
+ const promise = createAjaxRequest({
+ url: '/history',
+ data: queryParams
+ }).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data: data.records }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ },
+
+ [ALBUM_HISTORY_MARK_AS_FAILED]: function(getState, payload, dispatch) {
+ const {
+ historyId,
+ albumId
+ } = payload;
+
+ const promise = createAjaxRequest({
+ url: '/history/failed',
+ method: 'POST',
+ dataType: 'json',
+ data: {
+ id: historyId
+ }
+ }).request;
+
+ promise.done(() => {
+ dispatch(fetchAlbumHistory({ albumId }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_ALBUM_HISTORY]: (state) => {
+ return Object.assign({}, state, defaultState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/albumSelectionActions.js b/frontend/src/Store/Actions/albumSelectionActions.js
new file mode 100644
index 000000000..f19f5b691
--- /dev/null
+++ b/frontend/src/Store/Actions/albumSelectionActions.js
@@ -0,0 +1,86 @@
+import moment from 'moment';
+import { createAction } from 'redux-actions';
+import { sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+
+//
+// Variables
+
+export const section = 'albumSelection';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isReprocessing: false,
+ isPopulated: false,
+ error: null,
+ sortKey: 'title',
+ sortDirection: sortDirections.ASCENDING,
+ items: [],
+ sortPredicates: {
+ title: ({ title }) => {
+ return title.toLocaleLowerCase();
+ },
+
+ releaseDate: function({ releaseDate }, direction) {
+ if (releaseDate) {
+ return moment(releaseDate).unix();
+ }
+
+ if (direction === sortDirections.DESCENDING) {
+ return 0;
+ }
+
+ return Number.MAX_VALUE;
+ }
+ }
+};
+
+export const persistState = [
+ 'albumSelection.sortKey',
+ 'albumSelection.sortDirection'
+];
+
+//
+// Actions Types
+
+export const FETCH_ALBUMS = 'albumSelection/fetchAlbums';
+export const SET_ALBUMS_SORT = 'albumSelection/setAlbumsSort';
+export const CLEAR_ALBUMS = 'albumSelection/clearAlbums';
+
+//
+// Action Creators
+
+export const fetchAlbums = createThunk(FETCH_ALBUMS);
+export const setAlbumsSort = createAction(SET_ALBUMS_SORT);
+export const clearAlbums = createAction(CLEAR_ALBUMS);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_ALBUMS]: createFetchHandler(section, '/album')
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_ALBUMS_SORT]: createSetClientSideCollectionSortReducer(section),
+
+ [CLEAR_ALBUMS]: (state) => {
+ return updateSectionState(state, section, {
+ ...defaultState,
+ sortKey: state.sortKey,
+ sortDirection: state.sortDirection
+ });
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/appActions.js b/frontend/src/Store/Actions/appActions.js
new file mode 100644
index 000000000..972fd1be2
--- /dev/null
+++ b/frontend/src/Store/Actions/appActions.js
@@ -0,0 +1,226 @@
+import _ from 'lodash';
+import { createAction } from 'redux-actions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import { fetchTranslations as fetchAppTranslations } from 'Utilities/String/translate';
+import createHandleActions from './Creators/createHandleActions';
+
+function getDimensions(width, height) {
+ const dimensions = {
+ width,
+ height,
+ isExtraSmallScreen: width <= 480,
+ isSmallScreen: width <= 768,
+ isMediumScreen: width <= 992,
+ isLargeScreen: width <= 1200
+ };
+
+ return dimensions;
+}
+
+//
+// Variables
+
+export const section = 'app';
+const messagesSection = 'app.messages';
+let abortPingServer = null;
+let pingTimeout = null;
+
+//
+// State
+
+export const defaultState = {
+ dimensions: getDimensions(window.innerWidth, window.innerHeight),
+ messages: {
+ items: []
+ },
+ version: window.Lidarr.version,
+ isUpdated: false,
+ isConnected: true,
+ isReconnecting: false,
+ isDisconnected: false,
+ isRestarting: false,
+ isSidebarVisible: !getDimensions(window.innerWidth, window.innerHeight).isSmallScreen,
+ translations: {
+ isFetching: true,
+ isPopulated: false,
+ error: null
+ }
+};
+
+//
+// Action Types
+
+export const SHOW_MESSAGE = 'app/showMessage';
+export const HIDE_MESSAGE = 'app/hideMessage';
+export const SAVE_DIMENSIONS = 'app/saveDimensions';
+export const SET_VERSION = 'app/setVersion';
+export const SET_APP_VALUE = 'app/setAppValue';
+export const SET_IS_SIDEBAR_VISIBLE = 'app/setIsSidebarVisible';
+export const FETCH_TRANSLATIONS = 'app/fetchTranslations';
+
+export const PING_SERVER = 'app/pingServer';
+
+//
+// Action Creators
+
+export const saveDimensions = createAction(SAVE_DIMENSIONS);
+export const setVersion = createAction(SET_VERSION);
+export const setIsSidebarVisible = createAction(SET_IS_SIDEBAR_VISIBLE);
+export const setAppValue = createAction(SET_APP_VALUE);
+export const showMessage = createAction(SHOW_MESSAGE);
+export const hideMessage = createAction(HIDE_MESSAGE);
+export const pingServer = createThunk(PING_SERVER);
+export const fetchTranslations = createThunk(FETCH_TRANSLATIONS);
+
+//
+// Helpers
+
+function pingServerAfterTimeout(getState, dispatch) {
+ if (abortPingServer) {
+ abortPingServer();
+ abortPingServer = null;
+ }
+
+ if (pingTimeout) {
+ clearTimeout(pingTimeout);
+ pingTimeout = null;
+ }
+
+ pingTimeout = setTimeout(() => {
+ if (!getState().isRestarting && getState().isConnected) {
+ return;
+ }
+
+ const ajaxOptions = {
+ url: '/system/status',
+ method: 'GET',
+ contentType: 'application/json'
+ };
+
+ const { request, abortRequest } = createAjaxRequest(ajaxOptions);
+
+ abortPingServer = abortRequest;
+
+ request.done(() => {
+ abortPingServer = null;
+ pingTimeout = null;
+
+ dispatch(setAppValue({
+ isRestarting: false
+ }));
+ });
+
+ request.fail((xhr) => {
+ abortPingServer = null;
+ pingTimeout = null;
+
+ // Unauthorized, but back online
+ if (xhr.status === 401) {
+ dispatch(setAppValue({
+ isRestarting: false
+ }));
+ } else {
+ pingServerAfterTimeout(getState, dispatch);
+ }
+ });
+ }, 5000);
+}
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [PING_SERVER]: function(getState, payload, dispatch) {
+ pingServerAfterTimeout(getState, dispatch);
+ },
+ [FETCH_TRANSLATIONS]: async function(getState, payload, dispatch) {
+ const isFetchingComplete = await fetchAppTranslations();
+
+ dispatch(setAppValue({
+ translations: {
+ isFetching: false,
+ isPopulated: isFetchingComplete,
+ error: isFetchingComplete ? null : 'Failed to load translations from API'
+ }
+ }));
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SAVE_DIMENSIONS]: function(state, { payload }) {
+ const {
+ width,
+ height
+ } = payload;
+
+ const dimensions = getDimensions(width, height);
+
+ return Object.assign({}, state, { dimensions });
+ },
+
+ [SHOW_MESSAGE]: function(state, { payload }) {
+ const newState = getSectionState(state, messagesSection);
+ const items = newState.items;
+ const index = _.findIndex(items, { id: payload.id });
+
+ newState.items = [...items];
+
+ if (index >= 0) {
+ const item = items[index];
+
+ newState.items.splice(index, 1, { ...item, ...payload });
+ } else {
+ newState.items.push({ ...payload });
+ }
+
+ return updateSectionState(state, messagesSection, newState);
+ },
+
+ [HIDE_MESSAGE]: function(state, { payload }) {
+ const newState = getSectionState(state, messagesSection);
+
+ newState.items = [...newState.items];
+ _.remove(newState.items, { id: payload.id });
+
+ return updateSectionState(state, messagesSection, newState);
+ },
+
+ [SET_APP_VALUE]: function(state, { payload }) {
+ const newState = Object.assign(getSectionState(state, section), payload);
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [SET_VERSION]: function(state, { payload }) {
+ const version = payload.version;
+
+ const newState = {
+ version
+ };
+
+ if (state.version !== version) {
+ if (!state.prevVersion) {
+ newState.prevVersion = state.version;
+ }
+ newState.isUpdated = true;
+ }
+
+ return Object.assign({}, state, newState);
+ },
+
+ [SET_IS_SIDEBAR_VISIBLE]: function(state, { payload }) {
+ const newState = {
+ isSidebarVisible: payload.isSidebarVisible
+ };
+
+ return Object.assign({}, state, newState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/artistActions.js b/frontend/src/Store/Actions/artistActions.js
new file mode 100644
index 000000000..89384bcc4
--- /dev/null
+++ b/frontend/src/Store/Actions/artistActions.js
@@ -0,0 +1,511 @@
+import _ from 'lodash';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import { filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props';
+import { fetchAlbums } from 'Store/Actions/albumActions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import dateFilterPredicate from 'Utilities/Date/dateFilterPredicate';
+import translate from 'Utilities/String/translate';
+import { set, updateItem } from './baseActions';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import createSaveProviderHandler from './Creators/createSaveProviderHandler';
+import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer';
+
+//
+// Variables
+
+export const section = 'artist';
+
+export const filters = [
+ {
+ key: 'all',
+ label: () => translate('All'),
+ filters: []
+ },
+ {
+ key: 'monitored',
+ label: () => translate('MonitoredOnly'),
+ filters: [
+ {
+ key: 'monitored',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'unmonitored',
+ label: () => translate('UnmonitoredOnly'),
+ filters: [
+ {
+ key: 'monitored',
+ value: false,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'continuing',
+ label: () => translate('ContinuingOnly'),
+ filters: [
+ {
+ key: 'status',
+ value: 'continuing',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'ended',
+ label: () => translate('EndedOnly'),
+ filters: [
+ {
+ key: 'status',
+ value: 'ended',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'missing',
+ label: () => translate('MissingTracks'),
+ filters: [
+ {
+ key: 'missing',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+];
+
+export const filterPredicates = {
+ missing: function(item) {
+ const { statistics = {} } = item;
+
+ return statistics.trackCount - statistics.trackFileCount > 0;
+ },
+
+ nextAlbum: function(item, filterValue, type) {
+ return dateFilterPredicate(item.nextAlbum?.releaseDate, filterValue, type);
+ },
+
+ lastAlbum: function(item, filterValue, type) {
+ return dateFilterPredicate(item.lastAlbum?.releaseDate, filterValue, type);
+ },
+
+ added: function(item, filterValue, type) {
+ return dateFilterPredicate(item.added, filterValue, type);
+ },
+
+ ratings: function(item, filterValue, type) {
+ const predicate = filterTypePredicates[type];
+
+ return predicate(item.ratings.value * 10, filterValue);
+ },
+
+ albumCount: function(item, filterValue, type) {
+ const predicate = filterTypePredicates[type];
+ const albumCount = item.statistics ? item.statistics.albumCount : 0;
+
+ return predicate(albumCount, filterValue);
+ },
+
+ sizeOnDisk: function(item, filterValue, type) {
+ const predicate = filterTypePredicates[type];
+ const sizeOnDisk = item.statistics && item.statistics.sizeOnDisk ?
+ item.statistics.sizeOnDisk :
+ 0;
+
+ return predicate(sizeOnDisk, filterValue);
+ }
+};
+
+export const sortPredicates = {
+ status: function(item) {
+ let result = 0;
+
+ if (item.monitored) {
+ result += 2;
+ }
+
+ if (item.status === 'continuing') {
+ result++;
+ }
+
+ return result;
+ },
+
+ sizeOnDisk: function(item) {
+ const { statistics = {} } = item;
+
+ return statistics.sizeOnDisk || 0;
+ }
+};
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ isDeleting: false,
+ deleteError: null,
+ items: [],
+ sortKey: 'sortName',
+ sortDirection: sortDirections.ASCENDING,
+ pendingChanges: {},
+ deleteOptions: {
+ addImportListExclusion: false
+ }
+};
+
+export const persistState = [
+ 'artist.deleteOptions'
+];
+
+//
+// Actions Types
+
+export const FETCH_ARTIST = 'artist/fetchArtist';
+export const SET_ARTIST_VALUE = 'artist/setArtistValue';
+export const SAVE_ARTIST = 'artist/saveArtist';
+export const DELETE_ARTIST = 'artist/deleteArtist';
+
+export const TOGGLE_ARTIST_MONITORED = 'artist/toggleArtistMonitored';
+export const TOGGLE_ALBUM_MONITORED = 'artist/toggleAlbumMonitored';
+export const UPDATE_ARTISTS_MONITOR = 'artist/updateArtistsMonitor';
+export const SAVE_ARTIST_EDITOR = 'artist/saveArtistEditor';
+export const BULK_DELETE_ARTIST = 'artist/bulkDeleteArtist';
+
+export const SET_DELETE_OPTION = 'artist/setDeleteOption';
+
+//
+// Action Creators
+
+export const fetchArtist = createThunk(FETCH_ARTIST);
+export const saveArtist = createThunk(SAVE_ARTIST, (payload) => {
+ const newPayload = {
+ ...payload
+ };
+
+ if (payload.moveFiles) {
+ newPayload.queryParams = {
+ moveFiles: true
+ };
+ }
+
+ delete newPayload.moveFiles;
+
+ return newPayload;
+});
+
+export const deleteArtist = createThunk(DELETE_ARTIST, (payload) => {
+ return {
+ ...payload,
+ queryParams: {
+ deleteFiles: payload.deleteFiles,
+ addImportListExclusion: payload.addImportListExclusion
+ }
+ };
+});
+
+export const toggleArtistMonitored = createThunk(TOGGLE_ARTIST_MONITORED);
+export const toggleAlbumMonitored = createThunk(TOGGLE_ALBUM_MONITORED);
+export const updateArtistsMonitor = createThunk(UPDATE_ARTISTS_MONITOR);
+export const saveArtistEditor = createThunk(SAVE_ARTIST_EDITOR);
+export const bulkDeleteArtist = createThunk(BULK_DELETE_ARTIST);
+
+export const setArtistValue = createAction(SET_ARTIST_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const setDeleteOption = createAction(SET_DELETE_OPTION);
+
+//
+// Helpers
+
+function getSaveAjaxOptions({ ajaxOptions, payload }) {
+ if (payload.moveFolder) {
+ ajaxOptions.url = `${ajaxOptions.url}?moveFolder=true`;
+ }
+
+ return ajaxOptions;
+}
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_ARTIST]: createFetchHandler(section, '/artist'),
+ [SAVE_ARTIST]: createSaveProviderHandler(section, '/artist', { getAjaxOptions: getSaveAjaxOptions }),
+ [DELETE_ARTIST]: createRemoveItemHandler(section, '/artist'),
+
+ [TOGGLE_ARTIST_MONITORED]: (getState, payload, dispatch) => {
+ const {
+ artistId: id,
+ monitored
+ } = payload;
+
+ const artist = _.find(getState().artist.items, { id });
+
+ dispatch(updateItem({
+ id,
+ section,
+ isSaving: true
+ }));
+
+ const promise = createAjaxRequest({
+ url: `/artist/${id}`,
+ method: 'PUT',
+ data: JSON.stringify({
+ ...artist,
+ monitored
+ }),
+ dataType: 'json'
+ }).request;
+
+ promise.done((data) => {
+ dispatch(updateItem({
+ id,
+ section,
+ isSaving: false,
+ monitored
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ id,
+ section,
+ isSaving: false
+ }));
+ });
+ },
+
+ [TOGGLE_ALBUM_MONITORED]: function(getState, payload, dispatch) {
+ const {
+ artistId: id,
+ seasonNumber,
+ monitored
+ } = payload;
+
+ const artist = _.find(getState().artist.items, { id });
+ const seasons = _.cloneDeep(artist.seasons);
+ const season = _.find(seasons, { seasonNumber });
+
+ season.isSaving = true;
+
+ dispatch(updateItem({
+ id,
+ section,
+ seasons
+ }));
+
+ season.monitored = monitored;
+
+ const promise = createAjaxRequest({
+ url: `/artist/${id}`,
+ method: 'PUT',
+ data: JSON.stringify({
+ ...artist,
+ seasons
+ }),
+ dataType: 'json'
+ }).request;
+
+ promise.done((data) => {
+ const albums = _.filter(getState().albums.items, { artistId: id, seasonNumber });
+
+ dispatch(batchActions([
+ updateItem({
+ id,
+ section,
+ ...data
+ }),
+
+ ...albums.map((album) => {
+ return updateItem({
+ id: album.id,
+ section: 'albums',
+ monitored
+ });
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ id,
+ section,
+ seasons: artist.seasons
+ }));
+ });
+ },
+
+ [UPDATE_ARTISTS_MONITOR]: function(getState, payload, dispatch) {
+ const {
+ artistIds,
+ monitor,
+ monitored,
+ monitorNewItems,
+ shouldFetchAlbumsAfterUpdate = false
+ } = payload;
+
+ const artists = [];
+
+ artistIds.forEach((id) => {
+ const artistsToUpdate = { id };
+
+ if (monitored != null) {
+ artistsToUpdate.monitored = monitored;
+ }
+
+ artists.push(artistsToUpdate);
+ });
+
+ dispatch(set({
+ section,
+ isSaving: true
+ }));
+
+ const promise = createAjaxRequest({
+ url: '/albumStudio',
+ method: 'POST',
+ data: JSON.stringify({
+ artist: artists,
+ monitoringOptions: { monitor },
+ monitorNewItems
+ }),
+ dataType: 'json'
+ }).request;
+
+ promise.done((data) => {
+ if (shouldFetchAlbumsAfterUpdate) {
+ dispatch(fetchAlbums({ artistId: artistIds[0] }));
+ }
+
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ },
+
+ [SAVE_ARTIST_EDITOR]: function(getState, payload, dispatch) {
+ dispatch(set({
+ section,
+ isSaving: true
+ }));
+
+ const promise = createAjaxRequest({
+ url: '/artist/editor',
+ method: 'PUT',
+ data: JSON.stringify(payload),
+ dataType: 'json'
+ }).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ ...data.map((artist) => {
+
+ const {
+ images,
+ rootFolderPath,
+ statistics,
+ ...propsToUpdate
+ } = artist;
+
+ return updateItem({
+ id: artist.id,
+ section: 'artist',
+ ...propsToUpdate
+ });
+ }),
+
+ set({
+ section,
+ isSaving: false,
+ saveError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ },
+
+ [BULK_DELETE_ARTIST]: function(getState, payload, dispatch) {
+ dispatch(set({
+ section,
+ isDeleting: true
+ }));
+
+ const promise = createAjaxRequest({
+ url: '/artist/editor',
+ method: 'DELETE',
+ data: JSON.stringify(payload),
+ dataType: 'json'
+ }).request;
+
+ promise.done(() => {
+ // SignaR will take care of removing the artist from the collection
+
+ dispatch(set({
+ section,
+ isDeleting: false,
+ deleteError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isDeleting: false,
+ deleteError: xhr
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_ARTIST_VALUE]: createSetSettingValueReducer(section),
+
+ [SET_DELETE_OPTION]: (state, { payload }) => {
+ return {
+ ...state,
+ deleteOptions: {
+ ...payload
+ }
+ };
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/artistHistoryActions.js b/frontend/src/Store/Actions/artistHistoryActions.js
new file mode 100644
index 000000000..59ce098d0
--- /dev/null
+++ b/frontend/src/Store/Actions/artistHistoryActions.js
@@ -0,0 +1,101 @@
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { set, update } from './baseActions';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'artistHistory';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+};
+
+//
+// Actions Types
+
+export const FETCH_ARTIST_HISTORY = 'artistHistory/fetchArtistHistory';
+export const CLEAR_ARTIST_HISTORY = 'artistHistory/clearArtistHistory';
+export const ARTIST_HISTORY_MARK_AS_FAILED = 'artistHistory/artistHistoryMarkAsFailed';
+
+//
+// Action Creators
+
+export const fetchArtistHistory = createThunk(FETCH_ARTIST_HISTORY);
+export const clearArtistHistory = createAction(CLEAR_ARTIST_HISTORY);
+export const artistHistoryMarkAsFailed = createThunk(ARTIST_HISTORY_MARK_AS_FAILED);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_ARTIST_HISTORY]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ const promise = createAjaxRequest({
+ url: '/history/artist',
+ data: payload
+ }).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ },
+
+ [ARTIST_HISTORY_MARK_AS_FAILED]: function(getState, payload, dispatch) {
+ const {
+ historyId,
+ artistId,
+ albumId
+ } = payload;
+
+ const promise = createAjaxRequest({
+ url: `/history/failed/${historyId}`,
+ method: 'POST',
+ dataType: 'json'
+ }).request;
+
+ promise.done(() => {
+ dispatch(fetchArtistHistory({ artistId, albumId }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_ARTIST_HISTORY]: (state) => {
+ return Object.assign({}, state, defaultState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/artistIndexActions.js b/frontend/src/Store/Actions/artistIndexActions.js
new file mode 100644
index 000000000..736502460
--- /dev/null
+++ b/frontend/src/Store/Actions/artistIndexActions.js
@@ -0,0 +1,439 @@
+import { createAction } from 'redux-actions';
+import { filterBuilderTypes, filterBuilderValueTypes, filterTypePredicates, sortDirections } from 'Helpers/Props';
+import sortByProp from 'Utilities/Array/sortByProp';
+import translate from 'Utilities/String/translate';
+import { filterPredicates, filters, sortPredicates } from './artistActions';
+import createHandleActions from './Creators/createHandleActions';
+import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+
+//
+// Variables
+
+export const section = 'artistIndex';
+
+//
+// State
+
+export const defaultState = {
+ sortKey: 'sortName',
+ sortDirection: sortDirections.ASCENDING,
+ secondarySortKey: 'sortName',
+ secondarySortDirection: sortDirections.ASCENDING,
+ view: 'posters',
+
+ posterOptions: {
+ detailedProgressBar: false,
+ size: 'large',
+ showTitle: true,
+ showMonitored: true,
+ showQualityProfile: true,
+ showNextAlbum: true,
+ showSearchAction: false
+ },
+
+ bannerOptions: {
+ detailedProgressBar: false,
+ size: 'large',
+ showTitle: false,
+ showMonitored: true,
+ showQualityProfile: true,
+ showNextAlbum: true,
+ showSearchAction: false
+ },
+
+ overviewOptions: {
+ detailedProgressBar: false,
+ size: 'medium',
+ showMonitored: true,
+ showQualityProfile: true,
+ showLastAlbum: false,
+ showAdded: false,
+ showAlbumCount: true,
+ showPath: false,
+ showSizeOnDisk: false,
+ showSearchAction: false
+ },
+
+ tableOptions: {
+ showBanners: false,
+ showSearchAction: false
+ },
+
+ columns: [
+ {
+ name: 'status',
+ columnLabel: () => translate('Status'),
+ isSortable: true,
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'sortName',
+ label: () => translate('ArtistName'),
+ isSortable: true,
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'artistType',
+ label: () => translate('Type'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'qualityProfileId',
+ label: () => translate('QualityProfile'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'metadataProfileId',
+ label: () => translate('MetadataProfile'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'monitorNewItems',
+ label: () => translate('MonitorNewItems'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'nextAlbum',
+ label: () => translate('NextAlbum'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'lastAlbum',
+ label: () => translate('LastAlbum'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'added',
+ label: () => translate('Added'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'albumCount',
+ label: () => translate('Albums'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'trackProgress',
+ label: () => translate('Tracks'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'trackCount',
+ label: () => translate('TrackCount'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'path',
+ label: () => translate('Path'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'sizeOnDisk',
+ label: () => translate('SizeOnDisk'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'genres',
+ label: () => translate('Genres'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'ratings',
+ label: () => translate('Rating'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'tags',
+ label: () => translate('Tags'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'actions',
+ columnLabel: () => translate('Actions'),
+ isVisible: true,
+ isModifiable: false
+ }
+ ],
+
+ sortPredicates: {
+ ...sortPredicates,
+
+ trackProgress: function(item) {
+ const { statistics = {} } = item;
+
+ const {
+ trackCount = 0,
+ trackFileCount = 0
+ } = statistics;
+
+ const progress = trackCount ? trackFileCount / trackCount * 100 : 100;
+
+ return progress + trackCount / 1000000;
+ },
+
+ nextAlbum: function(item) {
+ if (item.nextAlbum) {
+ return item.nextAlbum.releaseDate;
+ }
+ return '1/1/1000';
+ },
+
+ lastAlbum: function(item) {
+ if (item.lastAlbum) {
+ return item.lastAlbum.releaseDate;
+ }
+ return '1/1/1000';
+ },
+
+ albumCount: function(item) {
+ const { statistics = {} } = item;
+
+ return statistics.albumCount || 0;
+ },
+
+ trackCount: function(item) {
+ const { statistics = {} } = item;
+
+ return statistics.totalTrackCount || 0;
+ },
+
+ ratings: function(item) {
+ const { ratings = {} } = item;
+
+ return ratings.value;
+ }
+ },
+
+ selectedFilterKey: 'all',
+
+ filters,
+
+ filterPredicates: {
+ ...filterPredicates,
+
+ trackProgress: function(item, filterValue, type) {
+ const { statistics = {} } = item;
+
+ const {
+ trackCount = 0,
+ trackFileCount = 0
+ } = statistics;
+
+ const progress = trackCount ?
+ trackFileCount / trackCount * 100 :
+ 100;
+
+ const predicate = filterTypePredicates[type];
+
+ return predicate(progress, filterValue);
+ }
+ },
+
+ filterBuilderProps: [
+ {
+ name: 'monitored',
+ label: () => translate('Monitored'),
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.BOOL
+ },
+ {
+ name: 'status',
+ label: () => translate('Status'),
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.ARTIST_STATUS
+ },
+ {
+ name: 'qualityProfileId',
+ label: () => translate('QualityProfile'),
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.QUALITY_PROFILE
+ },
+ {
+ name: 'metadataProfileId',
+ label: () => translate('MetadataProfile'),
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.METADATA_PROFILE
+ },
+ {
+ name: 'monitorNewItems',
+ label: () => translate('MonitorNewItems'),
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.MONITOR_NEW_ITEMS
+ },
+ {
+ name: 'nextAlbum',
+ label: () => translate('NextAlbum'),
+ type: filterBuilderTypes.DATE,
+ valueType: filterBuilderValueTypes.DATE
+ },
+ {
+ name: 'lastAlbum',
+ label: () => translate('LastAlbum'),
+ type: filterBuilderTypes.DATE,
+ valueType: filterBuilderValueTypes.DATE
+ },
+ {
+ name: 'added',
+ label: () => translate('Added'),
+ type: filterBuilderTypes.DATE,
+ valueType: filterBuilderValueTypes.DATE
+ },
+ {
+ name: 'albumCount',
+ label: () => translate('AlbumCount'),
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'trackProgress',
+ label: () => translate('TrackProgress'),
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'path',
+ label: () => translate('Path'),
+ type: filterBuilderTypes.STRING
+ },
+ {
+ name: 'sizeOnDisk',
+ label: () => translate('SizeOnDisk'),
+ type: filterBuilderTypes.NUMBER,
+ valueType: filterBuilderValueTypes.BYTES
+ },
+ {
+ name: 'genres',
+ label: () => translate('Genres'),
+ type: filterBuilderTypes.ARRAY,
+ optionsSelector: function(items) {
+ const tagList = items.reduce((acc, artist) => {
+ artist.genres.forEach((genre) => {
+ acc.push({
+ id: genre,
+ name: genre
+ });
+ });
+
+ return acc;
+ }, []);
+
+ return tagList.sort(sortByProp('name'));
+ }
+ },
+ {
+ name: 'ratings',
+ label: () => translate('Rating'),
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'tags',
+ label: () => translate('Tags'),
+ type: filterBuilderTypes.ARRAY,
+ valueType: filterBuilderValueTypes.TAG
+ }
+ ]
+};
+
+export const persistState = [
+ 'artistIndex.sortKey',
+ 'artistIndex.sortDirection',
+ 'artistIndex.selectedFilterKey',
+ 'artistIndex.customFilters',
+ 'artistIndex.view',
+ 'artistIndex.columns',
+ 'artistIndex.posterOptions',
+ 'artistIndex.bannerOptions',
+ 'artistIndex.overviewOptions',
+ 'artistIndex.tableOptions'
+];
+
+//
+// Actions Types
+
+export const SET_ARTIST_SORT = 'artistIndex/setArtistSort';
+export const SET_ARTIST_FILTER = 'artistIndex/setArtistFilter';
+export const SET_ARTIST_VIEW = 'artistIndex/setArtistView';
+export const SET_ARTIST_TABLE_OPTION = 'artistIndex/setArtistTableOption';
+export const SET_ARTIST_POSTER_OPTION = 'artistIndex/setArtistPosterOption';
+export const SET_ARTIST_BANNER_OPTION = 'artistIndex/setArtistBannerOption';
+export const SET_ARTIST_OVERVIEW_OPTION = 'artistIndex/setArtistOverviewOption';
+
+//
+// Action Creators
+
+export const setArtistSort = createAction(SET_ARTIST_SORT);
+export const setArtistFilter = createAction(SET_ARTIST_FILTER);
+export const setArtistView = createAction(SET_ARTIST_VIEW);
+export const setArtistTableOption = createAction(SET_ARTIST_TABLE_OPTION);
+export const setArtistPosterOption = createAction(SET_ARTIST_POSTER_OPTION);
+export const setArtistBannerOption = createAction(SET_ARTIST_BANNER_OPTION);
+export const setArtistOverviewOption = createAction(SET_ARTIST_OVERVIEW_OPTION);
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_ARTIST_SORT]: createSetClientSideCollectionSortReducer(section),
+ [SET_ARTIST_FILTER]: createSetClientSideCollectionFilterReducer(section),
+
+ [SET_ARTIST_VIEW]: function(state, { payload }) {
+ return Object.assign({}, state, { view: payload.view });
+ },
+
+ [SET_ARTIST_TABLE_OPTION]: createSetTableOptionReducer(section),
+
+ [SET_ARTIST_POSTER_OPTION]: function(state, { payload }) {
+ const posterOptions = state.posterOptions;
+
+ return {
+ ...state,
+ posterOptions: {
+ ...posterOptions,
+ ...payload
+ }
+ };
+ },
+
+ [SET_ARTIST_BANNER_OPTION]: function(state, { payload }) {
+ const bannerOptions = state.bannerOptions;
+
+ return {
+ ...state,
+ bannerOptions: {
+ ...bannerOptions,
+ ...payload
+ }
+ };
+ },
+
+ [SET_ARTIST_OVERVIEW_OPTION]: function(state, { payload }) {
+ const overviewOptions = state.overviewOptions;
+
+ return {
+ ...state,
+ overviewOptions: {
+ ...overviewOptions,
+ ...payload
+ }
+ };
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/baseActions.js b/frontend/src/Store/Actions/baseActions.js
new file mode 100644
index 000000000..37be3e0d2
--- /dev/null
+++ b/frontend/src/Store/Actions/baseActions.js
@@ -0,0 +1,29 @@
+import { createAction } from 'redux-actions';
+
+//
+// Action Types
+
+export const SET = 'base/set';
+
+export const UPDATE = 'base/update';
+export const UPDATE_ITEM = 'base/updateItem';
+export const UPDATE_SERVER_SIDE_COLLECTION = 'base/updateServerSideCollection';
+
+export const SET_SETTING_VALUE = 'base/setSettingValue';
+export const CLEAR_PENDING_CHANGES = 'base/clearPendingChanges';
+
+export const REMOVE_ITEM = 'base/removeItem';
+
+//
+// Action Creators
+
+export const set = createAction(SET);
+
+export const update = createAction(UPDATE);
+export const updateItem = createAction(UPDATE_ITEM);
+export const updateServerSideCollection = createAction(UPDATE_SERVER_SIDE_COLLECTION);
+
+export const setSettingValue = createAction(SET_SETTING_VALUE);
+export const clearPendingChanges = createAction(CLEAR_PENDING_CHANGES);
+
+export const removeItem = createAction(REMOVE_ITEM);
diff --git a/frontend/src/Store/Actions/blocklistActions.js b/frontend/src/Store/Actions/blocklistActions.js
new file mode 100644
index 000000000..217ee7f64
--- /dev/null
+++ b/frontend/src/Store/Actions/blocklistActions.js
@@ -0,0 +1,199 @@
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import { sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import translate from 'Utilities/String/translate';
+import { set, updateItem } from './baseActions';
+import createHandleActions from './Creators/createHandleActions';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+import createClearReducer from './Creators/Reducers/createClearReducer';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+
+//
+// Variables
+
+export const section = 'blocklist';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 20,
+ sortKey: 'date',
+ sortDirection: sortDirections.DESCENDING,
+ error: null,
+ items: [],
+ isRemoving: false,
+
+ columns: [
+ {
+ name: 'artists.sortName',
+ label: () => translate('ArtistName'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'sourceTitle',
+ label: () => translate('SourceTitle'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'quality',
+ label: () => translate('Quality'),
+ isVisible: true
+ },
+ {
+ name: 'customFormats',
+ label: 'Formats',
+ isSortable: false,
+ isVisible: true
+ },
+ {
+ name: 'date',
+ label: () => translate('Date'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'indexer',
+ label: () => translate('Indexer'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'actions',
+ columnLabel: () => translate('Actions'),
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+};
+
+export const persistState = [
+ 'blocklist.pageSize',
+ 'blocklist.sortKey',
+ 'blocklist.sortDirection',
+ 'blocklist.columns'
+];
+
+//
+// Action Types
+
+export const FETCH_BLOCKLIST = 'blocklist/fetchBlocklist';
+export const GOTO_FIRST_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistFirstPage';
+export const GOTO_PREVIOUS_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistPreviousPage';
+export const GOTO_NEXT_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistNextPage';
+export const GOTO_LAST_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistLastPage';
+export const GOTO_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistPage';
+export const SET_BLOCKLIST_SORT = 'blocklist/setBlocklistSort';
+export const SET_BLOCKLIST_TABLE_OPTION = 'blocklist/setBlocklistTableOption';
+export const REMOVE_BLOCKLIST_ITEM = 'blocklist/removeBlocklistItem';
+export const REMOVE_BLOCKLIST_ITEMS = 'blocklist/removeBlocklistItems';
+export const CLEAR_BLOCKLIST = 'blocklist/clearBlocklist';
+
+//
+// Action Creators
+
+export const fetchBlocklist = createThunk(FETCH_BLOCKLIST);
+export const gotoBlocklistFirstPage = createThunk(GOTO_FIRST_BLOCKLIST_PAGE);
+export const gotoBlocklistPreviousPage = createThunk(GOTO_PREVIOUS_BLOCKLIST_PAGE);
+export const gotoBlocklistNextPage = createThunk(GOTO_NEXT_BLOCKLIST_PAGE);
+export const gotoBlocklistLastPage = createThunk(GOTO_LAST_BLOCKLIST_PAGE);
+export const gotoBlocklistPage = createThunk(GOTO_BLOCKLIST_PAGE);
+export const setBlocklistSort = createThunk(SET_BLOCKLIST_SORT);
+export const setBlocklistTableOption = createAction(SET_BLOCKLIST_TABLE_OPTION);
+export const removeBlocklistItem = createThunk(REMOVE_BLOCKLIST_ITEM);
+export const removeBlocklistItems = createThunk(REMOVE_BLOCKLIST_ITEMS);
+export const clearBlocklist = createAction(CLEAR_BLOCKLIST);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ ...createServerSideCollectionHandlers(
+ section,
+ '/blocklist',
+ fetchBlocklist,
+ {
+ [serverSideCollectionHandlers.FETCH]: FETCH_BLOCKLIST,
+ [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_BLOCKLIST_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_BLOCKLIST_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_BLOCKLIST_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_BLOCKLIST_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_BLOCKLIST_PAGE,
+ [serverSideCollectionHandlers.SORT]: SET_BLOCKLIST_SORT
+ }),
+
+ [REMOVE_BLOCKLIST_ITEM]: createRemoveItemHandler(section, '/blocklist'),
+
+ [REMOVE_BLOCKLIST_ITEMS]: function(getState, payload, dispatch) {
+ const {
+ ids
+ } = payload;
+
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section,
+ id,
+ isRemoving: true
+ });
+ }),
+
+ set({ section, isRemoving: true })
+ ]));
+
+ const promise = createAjaxRequest({
+ url: '/blocklist/bulk',
+ method: 'DELETE',
+ dataType: 'json',
+ contentType: 'application/json',
+ data: JSON.stringify({ ids })
+ }).request;
+
+ promise.done((data) => {
+ // Don't use batchActions with thunks
+ dispatch(fetchBlocklist());
+
+ dispatch(set({ section, isRemoving: false }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section,
+ id,
+ isRemoving: false
+ });
+ }),
+
+ set({ section, isRemoving: false })
+ ]));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_BLOCKLIST_TABLE_OPTION]: createSetTableOptionReducer(section),
+
+ [CLEAR_BLOCKLIST]: createClearReducer(section, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ totalPages: 0,
+ totalRecords: 0
+ })
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/calendarActions.js b/frontend/src/Store/Actions/calendarActions.js
new file mode 100644
index 000000000..e13ff4672
--- /dev/null
+++ b/frontend/src/Store/Actions/calendarActions.js
@@ -0,0 +1,424 @@
+import _ from 'lodash';
+import moment from 'moment';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import * as calendarViews from 'Calendar/calendarViews';
+import * as commandNames from 'Commands/commandNames';
+import { filterBuilderTypes, filterBuilderValueTypes, filterTypes } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
+import translate from 'Utilities/String/translate';
+import { set, update } from './baseActions';
+import { executeCommandHelper } from './commandActions';
+import createHandleActions from './Creators/createHandleActions';
+import createClearReducer from './Creators/Reducers/createClearReducer';
+
+//
+// Variables
+
+export const section = 'calendar';
+
+const viewRanges = {
+ [calendarViews.DAY]: 'day',
+ [calendarViews.WEEK]: 'week',
+ [calendarViews.MONTH]: 'month',
+ [calendarViews.FORECAST]: 'day'
+};
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ start: null,
+ end: null,
+ dates: [],
+ dayCount: 7,
+ view: window.innerWidth > 768 ? 'week' : 'day',
+ showUpcoming: true,
+ error: null,
+ items: [],
+ searchMissingCommandId: null,
+
+ options: {
+ collapseMultipleAlbums: false,
+ showCutoffUnmetIcon: false
+ },
+
+ selectedFilterKey: 'monitored',
+
+ filters: [
+ {
+ key: 'all',
+ label: () => translate('All'),
+ filters: [
+ {
+ key: 'unmonitored',
+ value: [true],
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'monitored',
+ label: () => translate('MonitoredOnly'),
+ filters: [
+ {
+ key: 'unmonitored',
+ value: [false],
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+ ],
+
+ filterBuilderProps: [
+ {
+ name: 'unmonitored',
+ label: () => translate('IncludeUnmonitored'),
+ type: filterBuilderTypes.EQUAL,
+ valueType: filterBuilderValueTypes.BOOL
+ },
+ {
+ name: 'tags',
+ label: () => translate('Tags'),
+ type: filterBuilderTypes.CONTAINS,
+ valueType: filterBuilderValueTypes.TAG
+ }
+ ]
+};
+
+export const persistState = [
+ 'calendar.view',
+ 'calendar.selectedFilterKey',
+ 'calendar.options',
+ 'calendar.customFilters'
+];
+
+//
+// Actions Types
+
+export const FETCH_CALENDAR = 'calendar/fetchCalendar';
+export const SET_CALENDAR_DAYS_COUNT = 'calendar/setCalendarDaysCount';
+export const SET_CALENDAR_FILTER = 'calendar/setCalendarFilter';
+export const SET_CALENDAR_VIEW = 'calendar/setCalendarView';
+export const GOTO_CALENDAR_TODAY = 'calendar/gotoCalendarToday';
+export const GOTO_CALENDAR_NEXT_RANGE = 'calendar/gotoCalendarNextRange';
+export const CLEAR_CALENDAR = 'calendar/clearCalendar';
+export const SET_CALENDAR_OPTION = 'calendar/setCalendarOption';
+export const SEARCH_MISSING = 'calendar/searchMissing';
+export const GOTO_CALENDAR_PREVIOUS_RANGE = 'calendar/gotoCalendarPreviousRange';
+
+//
+// Helpers
+
+function getDays(start, end) {
+ const startTime = moment(start);
+ const endTime = moment(end);
+ const difference = endTime.diff(startTime, 'days');
+
+ // Difference is one less than the number of days we need to account for.
+ return _.times(difference + 1, (i) => {
+ return startTime.clone().add(i, 'days').toISOString();
+ });
+}
+
+function getDates(time, view, firstDayOfWeek, dayCount) {
+ const weekName = firstDayOfWeek === 0 ? 'week' : 'isoWeek';
+
+ let start = time.clone().startOf('day');
+ let end = time.clone().endOf('day');
+
+ if (view === calendarViews.WEEK) {
+ start = time.clone().startOf(weekName);
+ end = time.clone().endOf(weekName);
+ }
+
+ if (view === calendarViews.FORECAST) {
+ start = time.clone().subtract(1, 'day').startOf('day');
+ end = time.clone().add(dayCount - 2, 'days').endOf('day');
+ }
+
+ if (view === calendarViews.MONTH) {
+ start = time.clone().startOf('month').startOf(weekName);
+ end = time.clone().endOf('month').endOf(weekName);
+ }
+
+ if (view === calendarViews.AGENDA) {
+ start = time.clone().subtract(1, 'day').startOf('day');
+ end = time.clone().add(1, 'month').endOf('day');
+ }
+
+ return {
+ start: start.toISOString(),
+ end: end.toISOString(),
+ time: time.toISOString(),
+ dates: getDays(start, end)
+ };
+}
+
+function getPopulatableRange(startDate, endDate, view) {
+ switch (view) {
+ case calendarViews.DAY:
+ return {
+ start: moment(startDate).subtract(1, 'day').toISOString(),
+ end: moment(endDate).add(1, 'day').toISOString()
+ };
+ case calendarViews.WEEK:
+ case calendarViews.FORECAST:
+ return {
+ start: moment(startDate).subtract(1, 'week').toISOString(),
+ end: moment(endDate).add(1, 'week').toISOString()
+ };
+ default:
+ return {
+ start: startDate,
+ end: endDate
+ };
+ }
+}
+
+function isRangePopulated(start, end, state) {
+ const {
+ start: currentStart,
+ end: currentEnd,
+ view: currentView
+ } = state;
+
+ if (!currentStart || !currentEnd) {
+ return false;
+ }
+
+ const {
+ start: currentPopulatedStart,
+ end: currentPopulatedEnd
+ } = getPopulatableRange(currentStart, currentEnd, currentView);
+
+ if (
+ moment(start).isAfter(currentPopulatedStart) &&
+ moment(start).isBefore(currentPopulatedEnd)
+ ) {
+ return true;
+ }
+
+ return false;
+}
+
+function getCustomFilters(state, type) {
+ return state.customFilters.items.filter((customFilter) => customFilter.type === type);
+}
+
+//
+// Action Creators
+
+export const fetchCalendar = createThunk(FETCH_CALENDAR);
+export const setCalendarDaysCount = createThunk(SET_CALENDAR_DAYS_COUNT);
+export const setCalendarFilter = createThunk(SET_CALENDAR_FILTER);
+export const setCalendarView = createThunk(SET_CALENDAR_VIEW);
+export const gotoCalendarToday = createThunk(GOTO_CALENDAR_TODAY);
+export const gotoCalendarPreviousRange = createThunk(GOTO_CALENDAR_PREVIOUS_RANGE);
+export const gotoCalendarNextRange = createThunk(GOTO_CALENDAR_NEXT_RANGE);
+export const clearCalendar = createAction(CLEAR_CALENDAR);
+export const setCalendarOption = createAction(SET_CALENDAR_OPTION);
+export const searchMissing = createThunk(SEARCH_MISSING);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_CALENDAR]: function(getState, payload, dispatch) {
+ const state = getState();
+ const calendar = state.calendar;
+ const customFilters = getCustomFilters(state, section);
+ const selectedFilters = findSelectedFilters(calendar.selectedFilterKey, calendar.filters, customFilters);
+
+ const {
+ time = calendar.time,
+ view = calendar.view
+ } = payload;
+
+ const dayCount = state.calendar.dayCount;
+ const dates = getDates(moment(time), view, state.settings.ui.item.firstDayOfWeek, dayCount);
+ const { start, end } = getPopulatableRange(dates.start, dates.end, view);
+ const isPrePopulated = isRangePopulated(start, end, state.calendar);
+
+ const basesAttrs = {
+ section,
+ isFetching: true
+ };
+
+ const attrs = isPrePopulated ?
+ {
+ view,
+ ...basesAttrs,
+ ...dates
+ } :
+ basesAttrs;
+
+ dispatch(set(attrs));
+
+ const requestParams = {
+ start,
+ end
+ };
+
+ selectedFilters.forEach((selectedFilter) => {
+ if (selectedFilter.key === 'unmonitored') {
+ requestParams.unmonitored = selectedFilter.value.includes(true);
+ }
+
+ if (selectedFilter.key === 'tags') {
+ requestParams.tags = selectedFilter.value.join(',');
+ }
+ });
+
+ requestParams.unmonitored = requestParams.unmonitored ?? false;
+
+ const promise = createAjaxRequest({
+ url: '/calendar',
+ data: requestParams
+ }).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ view,
+ ...dates,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ },
+
+ [SET_CALENDAR_DAYS_COUNT]: function(getState, payload, dispatch) {
+ if (payload.dayCount === getState().calendar.dayCount) {
+ return;
+ }
+
+ dispatch(set({
+ section,
+ dayCount: payload.dayCount
+ }));
+
+ const state = getState();
+ const { time, view } = state.calendar;
+
+ dispatch(fetchCalendar({ time, view }));
+ },
+
+ [SET_CALENDAR_FILTER]: function(getState, payload, dispatch) {
+ dispatch(set({
+ section,
+ selectedFilterKey: payload.selectedFilterKey
+ }));
+
+ const state = getState();
+ const { time, view } = state.calendar;
+
+ dispatch(fetchCalendar({ time, view }));
+ },
+
+ [SET_CALENDAR_VIEW]: function(getState, payload, dispatch) {
+ const state = getState();
+ const view = payload.view;
+ const time = view === calendarViews.FORECAST || calendarViews.AGENDA ?
+ moment() :
+ state.calendar.time;
+
+ dispatch(fetchCalendar({ time, view }));
+ },
+
+ [GOTO_CALENDAR_TODAY]: function(getState, payload, dispatch) {
+ const state = getState();
+ const view = state.calendar.view;
+ const time = moment();
+
+ dispatch(fetchCalendar({ time, view }));
+ },
+
+ [GOTO_CALENDAR_PREVIOUS_RANGE]: function(getState, payload, dispatch) {
+ const state = getState();
+
+ const {
+ view,
+ dayCount
+ } = state.calendar;
+
+ const amount = view === calendarViews.FORECAST ? dayCount : 1;
+ const time = moment(state.calendar.time).subtract(amount, viewRanges[view]);
+
+ dispatch(fetchCalendar({ time, view }));
+ },
+
+ [GOTO_CALENDAR_NEXT_RANGE]: function(getState, payload, dispatch) {
+ const state = getState();
+
+ const {
+ view,
+ dayCount
+ } = state.calendar;
+
+ const amount = view === calendarViews.FORECAST ? dayCount : 1;
+ const time = moment(state.calendar.time).add(amount, viewRanges[view]);
+
+ dispatch(fetchCalendar({ time, view }));
+ },
+
+ [SEARCH_MISSING]: function(getState, payload, dispatch) {
+ const { albumIds } = payload;
+
+ const commandPayload = {
+ name: commandNames.ALBUM_SEARCH,
+ albumIds
+ };
+
+ executeCommandHelper(commandPayload, dispatch).then((data) => {
+ dispatch(set({
+ section,
+ searchMissingCommandId: data.id
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_CALENDAR]: createClearReducer(section, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ }),
+
+ [SET_CALENDAR_OPTION]: function(state, { payload }) {
+ const options = state.options;
+
+ return {
+ ...state,
+ options: {
+ ...options,
+ ...payload
+ }
+ };
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/captchaActions.js b/frontend/src/Store/Actions/captchaActions.js
new file mode 100644
index 000000000..c83d231b7
--- /dev/null
+++ b/frontend/src/Store/Actions/captchaActions.js
@@ -0,0 +1,119 @@
+import { createAction } from 'redux-actions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import requestAction from 'Utilities/requestAction';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'captcha';
+
+//
+// State
+
+export const defaultState = {
+ refreshing: false,
+ token: null,
+ siteKey: null,
+ secretToken: null,
+ ray: null,
+ stoken: null,
+ responseUrl: null
+};
+
+//
+// Actions Types
+
+export const REFRESH_CAPTCHA = 'captcha/refreshCaptcha';
+export const GET_CAPTCHA_COOKIE = 'captcha/getCaptchaCookie';
+export const SET_CAPTCHA_VALUE = 'captcha/setCaptchaValue';
+export const RESET_CAPTCHA = 'captcha/resetCaptcha';
+
+//
+// Action Creators
+
+export const refreshCaptcha = createThunk(REFRESH_CAPTCHA);
+export const getCaptchaCookie = createThunk(GET_CAPTCHA_COOKIE);
+export const setCaptchaValue = createAction(SET_CAPTCHA_VALUE);
+export const resetCaptcha = createAction(RESET_CAPTCHA);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [REFRESH_CAPTCHA]: function(getState, payload, dispatch) {
+ const actionPayload = {
+ action: 'checkCaptcha',
+ ...payload
+ };
+
+ dispatch(setCaptchaValue({
+ refreshing: true
+ }));
+
+ const promise = requestAction(actionPayload);
+
+ promise.done((data) => {
+ if (!data.captchaRequest) {
+ dispatch(setCaptchaValue({
+ refreshing: false
+ }));
+ }
+
+ dispatch(setCaptchaValue({
+ refreshing: false,
+ ...data.captchaRequest
+ }));
+ });
+
+ promise.fail(() => {
+ dispatch(setCaptchaValue({
+ refreshing: false
+ }));
+ });
+ },
+
+ [GET_CAPTCHA_COOKIE]: function(getState, payload, dispatch) {
+ const state = getState().captcha;
+
+ const queryParams = {
+ responseUrl: state.responseUrl,
+ ray: state.ray,
+ captchaResponse: payload.captchaResponse
+ };
+
+ const actionPayload = {
+ action: 'getCaptchaCookie',
+ queryParams,
+ ...payload
+ };
+
+ const promise = requestAction(actionPayload);
+
+ promise.done((data) => {
+ dispatch(setCaptchaValue({
+ token: data.captchaToken
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_CAPTCHA_VALUE]: function(state, { payload }) {
+ const newState = Object.assign(getSectionState(state, section), payload);
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [RESET_CAPTCHA]: function(state) {
+ return updateSectionState(state, section, defaultState);
+ }
+
+}, defaultState);
diff --git a/frontend/src/Store/Actions/commandActions.js b/frontend/src/Store/Actions/commandActions.js
new file mode 100644
index 000000000..083bdd61f
--- /dev/null
+++ b/frontend/src/Store/Actions/commandActions.js
@@ -0,0 +1,218 @@
+import { batchActions } from 'redux-batched-actions';
+import { messageTypes } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import { isSameCommand } from 'Utilities/Command';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { hideMessage, showMessage } from './appActions';
+import { removeItem, updateItem } from './baseActions';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+
+//
+// Variables
+
+export const section = 'commands';
+
+let lastCommand = null;
+let lastCommandTimeout = null;
+const removeCommandTimeoutIds = {};
+const commandFinishedCallbacks = {};
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ handlers: {}
+};
+
+//
+// Actions Types
+
+export const FETCH_COMMANDS = 'commands/fetchCommands';
+export const EXECUTE_COMMAND = 'commands/executeCommand';
+export const CANCEL_COMMAND = 'commands/cancelCommand';
+export const ADD_COMMAND = 'commands/addCommand';
+export const UPDATE_COMMAND = 'commands/updateCommand';
+export const FINISH_COMMAND = 'commands/finishCommand';
+export const REMOVE_COMMAND = 'commands/removeCommand';
+
+//
+// Action Creators
+
+export const fetchCommands = createThunk(FETCH_COMMANDS);
+export const executeCommand = createThunk(EXECUTE_COMMAND);
+export const cancelCommand = createThunk(CANCEL_COMMAND);
+export const addCommand = createThunk(ADD_COMMAND);
+export const updateCommand = createThunk(UPDATE_COMMAND);
+export const finishCommand = createThunk(FINISH_COMMAND);
+export const removeCommand = createThunk(REMOVE_COMMAND);
+
+//
+// Helpers
+
+function showCommandMessage(payload, dispatch) {
+ const {
+ id,
+ name,
+ trigger,
+ message,
+ body = {},
+ status
+ } = payload;
+
+ const {
+ sendUpdatesToClient,
+ suppressMessages
+ } = body;
+
+ if (!message || !body || !sendUpdatesToClient || suppressMessages) {
+ return;
+ }
+
+ let type = messageTypes.INFO;
+ let hideAfter = 0;
+
+ if (status === 'completed') {
+ type = messageTypes.SUCCESS;
+ hideAfter = 4;
+ } else if (status === 'failed') {
+ type = messageTypes.ERROR;
+ hideAfter = trigger === 'manual' ? 10 : 4;
+ }
+
+ dispatch(showMessage({
+ id,
+ name,
+ message,
+ type,
+ hideAfter
+ }));
+}
+
+function scheduleRemoveCommand(command, dispatch) {
+ const {
+ id,
+ status
+ } = command;
+
+ if (status === 'queued') {
+ return;
+ }
+
+ const timeoutId = removeCommandTimeoutIds[id];
+
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+
+ removeCommandTimeoutIds[id] = setTimeout(() => {
+ dispatch(batchActions([
+ removeCommand({ section: 'commands', id }),
+ hideMessage({ id })
+ ]));
+
+ delete removeCommandTimeoutIds[id];
+ }, 60000 * 5);
+}
+
+export function executeCommandHelper(payload, dispatch) {
+ // TODO: show a message for the user
+ if (lastCommand && isSameCommand(lastCommand, payload)) {
+ console.warn('Please wait at least 5 seconds before running this command again');
+ }
+
+ lastCommand = payload;
+
+ // clear last command after 5 seconds.
+ if (lastCommandTimeout) {
+ clearTimeout(lastCommandTimeout);
+ }
+
+ lastCommandTimeout = setTimeout(() => {
+ lastCommand = null;
+ }, 5000);
+
+ const {
+ commandFinished,
+ ...requestPayload
+ } = payload;
+
+ const promise = createAjaxRequest({
+ url: '/command',
+ method: 'POST',
+ data: JSON.stringify(requestPayload),
+ dataType: 'json'
+ }).request;
+
+ return promise.then((data) => {
+ if (commandFinished) {
+ commandFinishedCallbacks[data.id] = commandFinished;
+ }
+
+ dispatch(addCommand(data));
+ });
+}
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_COMMANDS]: createFetchHandler('commands', '/command'),
+
+ [EXECUTE_COMMAND]: function(getState, payload, dispatch) {
+ executeCommandHelper(payload, dispatch);
+ },
+
+ [CANCEL_COMMAND]: createRemoveItemHandler(section, '/command'),
+
+ [ADD_COMMAND]: function(getState, payload, dispatch) {
+ dispatch(updateItem({ section: 'commands', ...payload }));
+ },
+
+ [UPDATE_COMMAND]: function(getState, payload, dispatch) {
+ dispatch(updateItem({ section: 'commands', ...payload }));
+
+ showCommandMessage(payload, dispatch);
+ scheduleRemoveCommand(payload, dispatch);
+ },
+
+ [FINISH_COMMAND]: function(getState, payload, dispatch) {
+ const state = getState();
+ const handlers = state.commands.handlers;
+
+ Object.keys(handlers).forEach((key) => {
+ const handler = handlers[key];
+
+ if (handler.name === payload.name) {
+ dispatch(handler.handler(payload));
+ }
+ });
+
+ const commandFinished = commandFinishedCallbacks[payload.id];
+
+ if (commandFinished) {
+ commandFinished(payload);
+ }
+
+ delete commandFinishedCallbacks[payload.id];
+
+ dispatch(updateItem({ section: 'commands', ...payload }));
+ scheduleRemoveCommand(payload, dispatch);
+ showCommandMessage(payload, dispatch);
+ },
+
+ [REMOVE_COMMAND]: function(getState, payload, dispatch) {
+ dispatch(removeItem({ section: 'commands', ...payload }));
+ }
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({}, defaultState, section);
diff --git a/frontend/src/Store/Actions/createReducers.js b/frontend/src/Store/Actions/createReducers.js
new file mode 100644
index 000000000..28c06b9c3
--- /dev/null
+++ b/frontend/src/Store/Actions/createReducers.js
@@ -0,0 +1,23 @@
+import { connectRouter } from 'connected-react-router';
+import { combineReducers } from 'redux';
+import { enableBatching } from 'redux-batched-actions';
+import actions from 'Store/Actions';
+
+const defaultState = {};
+const reducers = {};
+
+actions.forEach((action) => {
+ const section = action.section;
+
+ defaultState[section] = action.defaultState;
+ reducers[section] = action.reducers;
+});
+
+export { defaultState };
+
+export default function(history) {
+ return enableBatching(combineReducers({
+ ...reducers,
+ router: connectRouter(history)
+ }));
+}
diff --git a/frontend/src/Store/Actions/customFilterActions.js b/frontend/src/Store/Actions/customFilterActions.js
new file mode 100644
index 000000000..d644afbf9
--- /dev/null
+++ b/frontend/src/Store/Actions/customFilterActions.js
@@ -0,0 +1,55 @@
+import { createThunk, handleThunks } from 'Store/thunks';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import createSaveProviderHandler from './Creators/createSaveProviderHandler';
+
+//
+// Variables
+
+export const section = 'customFilters';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ isDeleting: false,
+ deleteError: null,
+ items: [],
+ pendingChanges: {}
+};
+
+//
+// Actions Types
+
+export const FETCH_CUSTOM_FILTERS = 'customFilters/fetchCustomFilters';
+export const SAVE_CUSTOM_FILTER = 'customFilters/saveCustomFilter';
+export const DELETE_CUSTOM_FILTER = 'customFilters/deleteCustomFilter';
+
+//
+// Action Creators
+
+export const fetchCustomFilters = createThunk(FETCH_CUSTOM_FILTERS);
+export const saveCustomFilter = createThunk(SAVE_CUSTOM_FILTER);
+export const deleteCustomFilter = createThunk(DELETE_CUSTOM_FILTER);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_CUSTOM_FILTERS]: createFetchHandler(section, '/customFilter'),
+
+ [SAVE_CUSTOM_FILTER]: createSaveProviderHandler(section, '/customFilter'),
+
+ [DELETE_CUSTOM_FILTER]: createRemoveItemHandler(section, '/customFilter')
+
+});
+
+//
+// Reducers
+export const reducers = createHandleActions({}, defaultState, section);
diff --git a/frontend/src/Store/Actions/historyActions.js b/frontend/src/Store/Actions/historyActions.js
new file mode 100644
index 000000000..9d16d29c4
--- /dev/null
+++ b/frontend/src/Store/Actions/historyActions.js
@@ -0,0 +1,350 @@
+import React from 'react';
+import { createAction } from 'redux-actions';
+import Icon from 'Components/Icon';
+import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, icons, sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import translate from 'Utilities/String/translate';
+import { updateItem } from './baseActions';
+import createHandleActions from './Creators/createHandleActions';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+import createClearReducer from './Creators/Reducers/createClearReducer';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+
+//
+// Variables
+
+export const section = 'history';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pageSize: 20,
+ sortKey: 'date',
+ sortDirection: sortDirections.DESCENDING,
+ items: [],
+
+ columns: [
+ {
+ name: 'eventType',
+ columnLabel: () => translate('EventType'),
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'artists.sortName',
+ label: () => translate('Artist'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'albums.title',
+ label: () => translate('AlbumTitle'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'trackTitle',
+ label: () => translate('TrackTitle'),
+ isVisible: true
+ },
+ {
+ name: 'quality',
+ label: () => translate('Quality'),
+ isVisible: true
+ },
+ {
+ name: 'customFormats',
+ label: () => translate('Formats'),
+ isSortable: false,
+ isVisible: true
+ },
+ {
+ name: 'date',
+ label: () => translate('Date'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'downloadClient',
+ label: () => translate('DownloadClient'),
+ isVisible: false
+ },
+ {
+ name: 'indexer',
+ label: () => translate('Indexer'),
+ isVisible: false
+ },
+ {
+ name: 'releaseGroup',
+ label: () => translate('ReleaseGroup'),
+ isVisible: false
+ },
+ {
+ name: 'sourceTitle',
+ label: () => translate('SourceTitle'),
+ isVisible: false
+ },
+ {
+ name: 'customFormatScore',
+ columnLabel: () => translate('CustomFormatScore'),
+ label: React.createElement(Icon, {
+ name: icons.SCORE,
+ title: () => translate('CustomFormatScore')
+ }),
+ isVisible: false
+ },
+ {
+ name: 'details',
+ columnLabel: () => translate('Details'),
+ isVisible: true,
+ isModifiable: false
+ }
+ ],
+
+ selectedFilterKey: 'all',
+
+ filters: [
+ {
+ key: 'all',
+ label: () => translate('All'),
+ filters: []
+ },
+ {
+ key: 'grabbed',
+ label: () => translate('Grabbed'),
+ filters: [
+ {
+ key: 'eventType',
+ value: '1',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'trackFileImported',
+ label: () => translate('TrackImported'),
+ filters: [
+ {
+ key: 'eventType',
+ value: '3',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'failed',
+ label: () => translate('DownloadFailed'),
+ filters: [
+ {
+ key: 'eventType',
+ value: '4',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'importFailed',
+ label: () => translate('ImportCompleteFailed'),
+ filters: [
+ {
+ key: 'eventType',
+ value: '7',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'downloadImported',
+ label: () => translate('DownloadImported'),
+ filters: [
+ {
+ key: 'eventType',
+ value: '8',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'deleted',
+ label: () => translate('Deleted'),
+ filters: [
+ {
+ key: 'eventType',
+ value: '5',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'renamed',
+ label: () => translate('Renamed'),
+ filters: [
+ {
+ key: 'eventType',
+ value: '6',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'retagged',
+ label: () => translate('Retagged'),
+ filters: [
+ {
+ key: 'eventType',
+ value: '9',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'ignored',
+ label: () => translate('Ignored'),
+ filters: [
+ {
+ key: 'eventType',
+ value: '7',
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+ ],
+
+ filterBuilderProps: [
+ {
+ name: 'eventType',
+ label: () => translate('EventType'),
+ type: filterBuilderTypes.EQUAL,
+ valueType: filterBuilderValueTypes.HISTORY_EVENT_TYPE
+ },
+ {
+ name: 'artistIds',
+ label: () => translate('Artist'),
+ type: filterBuilderTypes.EQUAL,
+ valueType: filterBuilderValueTypes.ARTIST
+ },
+ {
+ name: 'quality',
+ label: () => translate('Quality'),
+ type: filterBuilderTypes.EQUAL,
+ valueType: filterBuilderValueTypes.QUALITY
+ }
+ ]
+
+};
+
+export const persistState = [
+ 'history.pageSize',
+ 'history.sortKey',
+ 'history.sortDirection',
+ 'history.selectedFilterKey',
+ 'history.columns'
+];
+
+//
+// Actions Types
+
+export const FETCH_HISTORY = 'history/fetchHistory';
+export const GOTO_FIRST_HISTORY_PAGE = 'history/gotoHistoryFirstPage';
+export const GOTO_PREVIOUS_HISTORY_PAGE = 'history/gotoHistoryPreviousPage';
+export const GOTO_NEXT_HISTORY_PAGE = 'history/gotoHistoryNextPage';
+export const GOTO_LAST_HISTORY_PAGE = 'history/gotoHistoryLastPage';
+export const GOTO_HISTORY_PAGE = 'history/gotoHistoryPage';
+export const SET_HISTORY_SORT = 'history/setHistorySort';
+export const SET_HISTORY_FILTER = 'history/setHistoryFilter';
+export const SET_HISTORY_TABLE_OPTION = 'history/setHistoryTableOption';
+export const CLEAR_HISTORY = 'history/clearHistory';
+export const MARK_AS_FAILED = 'history/markAsFailed';
+
+//
+// Action Creators
+
+export const fetchHistory = createThunk(FETCH_HISTORY);
+export const gotoHistoryFirstPage = createThunk(GOTO_FIRST_HISTORY_PAGE);
+export const gotoHistoryPreviousPage = createThunk(GOTO_PREVIOUS_HISTORY_PAGE);
+export const gotoHistoryNextPage = createThunk(GOTO_NEXT_HISTORY_PAGE);
+export const gotoHistoryLastPage = createThunk(GOTO_LAST_HISTORY_PAGE);
+export const gotoHistoryPage = createThunk(GOTO_HISTORY_PAGE);
+export const setHistorySort = createThunk(SET_HISTORY_SORT);
+export const setHistoryFilter = createThunk(SET_HISTORY_FILTER);
+export const setHistoryTableOption = createAction(SET_HISTORY_TABLE_OPTION);
+export const clearHistory = createAction(CLEAR_HISTORY);
+export const markAsFailed = createThunk(MARK_AS_FAILED);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ ...createServerSideCollectionHandlers(
+ section,
+ '/history',
+ fetchHistory,
+ {
+ [serverSideCollectionHandlers.FETCH]: FETCH_HISTORY,
+ [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_HISTORY_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_HISTORY_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_HISTORY_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_HISTORY_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_HISTORY_PAGE,
+ [serverSideCollectionHandlers.SORT]: SET_HISTORY_SORT,
+ [serverSideCollectionHandlers.FILTER]: SET_HISTORY_FILTER
+ }),
+
+ [MARK_AS_FAILED]: function(getState, payload, dispatch) {
+ const id = payload.id;
+
+ dispatch(updateItem({
+ section,
+ id,
+ isMarkingAsFailed: true
+ }));
+
+ const promise = createAjaxRequest({
+ url: `/history/failed/${id}`,
+ method: 'POST',
+ dataType: 'json'
+ }).request;
+
+ promise.done(() => {
+ dispatch(updateItem({
+ section,
+ id,
+ isMarkingAsFailed: false,
+ markAsFailedError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ section,
+ id,
+ isMarkingAsFailed: false,
+ markAsFailedError: xhr
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_HISTORY_TABLE_OPTION]: createSetTableOptionReducer(section),
+
+ [CLEAR_HISTORY]: createClearReducer(section, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ totalPages: 0,
+ totalRecords: 0
+ })
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js
new file mode 100644
index 000000000..85fda482b
--- /dev/null
+++ b/frontend/src/Store/Actions/index.js
@@ -0,0 +1,61 @@
+import * as albums from './albumActions';
+import * as albumHistory from './albumHistoryActions';
+import * as albumSelection from './albumSelectionActions';
+import * as app from './appActions';
+import * as artist from './artistActions';
+import * as artistHistory from './artistHistoryActions';
+import * as artistIndex from './artistIndexActions';
+import * as blocklist from './blocklistActions';
+import * as calendar from './calendarActions';
+import * as captcha from './captchaActions';
+import * as commands from './commandActions';
+import * as customFilters from './customFilterActions';
+import * as history from './historyActions';
+import * as interactiveImportActions from './interactiveImportActions';
+import * as oAuth from './oAuthActions';
+import * as organizePreview from './organizePreviewActions';
+import * as parse from './parseActions';
+import * as paths from './pathActions';
+import * as providerOptions from './providerOptionActions';
+import * as queue from './queueActions';
+import * as releases from './releaseActions';
+import * as retagPreview from './retagPreviewActions';
+import * as search from './searchActions';
+import * as settings from './settingsActions';
+import * as system from './systemActions';
+import * as tags from './tagActions';
+import * as tracks from './trackActions';
+import * as trackFiles from './trackFileActions';
+import * as wanted from './wantedActions';
+
+export default [
+ app,
+ albums,
+ albumHistory,
+ albumSelection,
+ artist,
+ artistHistory,
+ artistIndex,
+ blocklist,
+ captcha,
+ calendar,
+ commands,
+ customFilters,
+ trackFiles,
+ history,
+ interactiveImportActions,
+ oAuth,
+ organizePreview,
+ retagPreview,
+ parse,
+ paths,
+ providerOptions,
+ queue,
+ releases,
+ search,
+ settings,
+ system,
+ tags,
+ tracks,
+ wanted
+];
diff --git a/frontend/src/Store/Actions/interactiveImportActions.js b/frontend/src/Store/Actions/interactiveImportActions.js
new file mode 100644
index 000000000..a250292c5
--- /dev/null
+++ b/frontend/src/Store/Actions/interactiveImportActions.js
@@ -0,0 +1,325 @@
+import moment from 'moment';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import { sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import naturalExpansion from 'Utilities/String/naturalExpansion';
+import { set, update, updateItem } from './baseActions';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+
+//
+// Variables
+
+export const section = 'interactiveImport';
+
+const trackFilesSection = `${section}.trackFiles`;
+let abortCurrentFetchRequest = null;
+let abortCurrentRequest = null;
+let currentIds = [];
+
+const MAXIMUM_RECENT_FOLDERS = 10;
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ isSaving: false,
+ error: null,
+ items: [],
+ pendingChanges: {},
+ sortKey: 'path',
+ sortDirection: sortDirections.ASCENDING,
+ secondarySortKey: 'path',
+ secondarySortDirection: sortDirections.ASCENDING,
+ recentFolders: [],
+ importMode: 'chooseImportMode',
+ sortPredicates: {
+ path: function(item, direction) {
+ const path = item.path;
+
+ return naturalExpansion(path.toLowerCase());
+ },
+
+ artist: function(item, direction) {
+ const artist = item.artist;
+
+ return artist ? artist.sortName : '';
+ },
+
+ quality: function(item, direction) {
+ return item.qualityWeight || 0;
+ }
+ },
+
+ trackFiles: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ sortKey: 'relativePath',
+ sortDirection: sortDirections.ASCENDING,
+ items: []
+ }
+};
+
+export const persistState = [
+ 'interactiveImport.sortKey',
+ 'interactiveImport.sortDirection',
+ 'interactiveImport.recentFolders',
+ 'interactiveImport.importMode'
+];
+
+//
+// Actions Types
+
+export const FETCH_INTERACTIVE_IMPORT_ITEMS = 'interactiveImport/fetchInteractiveImportItems';
+export const SAVE_INTERACTIVE_IMPORT_ITEM = 'interactiveImport/saveInteractiveImportItem';
+export const SET_INTERACTIVE_IMPORT_SORT = 'interactiveImport/setInteractiveImportSort';
+export const UPDATE_INTERACTIVE_IMPORT_ITEM = 'interactiveImport/updateInteractiveImportItem';
+export const UPDATE_INTERACTIVE_IMPORT_ITEMS = 'interactiveImport/updateInteractiveImportItems';
+export const CLEAR_INTERACTIVE_IMPORT = 'interactiveImport/clearInteractiveImport';
+export const ADD_RECENT_FOLDER = 'interactiveImport/addRecentFolder';
+export const REMOVE_RECENT_FOLDER = 'interactiveImport/removeRecentFolder';
+export const SET_INTERACTIVE_IMPORT_MODE = 'interactiveImport/setInteractiveImportMode';
+
+export const FETCH_INTERACTIVE_IMPORT_TRACKFILES = 'interactiveImport/fetchInteractiveImportTrackFiles';
+export const CLEAR_INTERACTIVE_IMPORT_TRACKFILES = 'interactiveImport/clearInteractiveImportTrackFiles';
+
+//
+// Action Creators
+
+export const fetchInteractiveImportItems = createThunk(FETCH_INTERACTIVE_IMPORT_ITEMS);
+export const setInteractiveImportSort = createAction(SET_INTERACTIVE_IMPORT_SORT);
+export const updateInteractiveImportItem = createAction(UPDATE_INTERACTIVE_IMPORT_ITEM);
+export const updateInteractiveImportItems = createAction(UPDATE_INTERACTIVE_IMPORT_ITEMS);
+export const saveInteractiveImportItem = createThunk(SAVE_INTERACTIVE_IMPORT_ITEM);
+export const clearInteractiveImport = createAction(CLEAR_INTERACTIVE_IMPORT);
+export const addRecentFolder = createAction(ADD_RECENT_FOLDER);
+export const removeRecentFolder = createAction(REMOVE_RECENT_FOLDER);
+export const setInteractiveImportMode = createAction(SET_INTERACTIVE_IMPORT_MODE);
+
+export const fetchInteractiveImportTrackFiles = createThunk(FETCH_INTERACTIVE_IMPORT_TRACKFILES);
+export const clearInteractiveImportTrackFiles = createAction(CLEAR_INTERACTIVE_IMPORT_TRACKFILES);
+
+//
+// Action Handlers
+export const actionHandlers = handleThunks({
+ [FETCH_INTERACTIVE_IMPORT_ITEMS]: function(getState, payload, dispatch) {
+ if (abortCurrentFetchRequest) {
+ abortCurrentFetchRequest();
+ abortCurrentFetchRequest = null;
+ }
+
+ if (!payload.downloadId && !payload.folder) {
+ dispatch(set({ section, error: { message: '`downloadId` or `folder` is required.' } }));
+ return;
+ }
+
+ dispatch(set({ section, isFetching: true }));
+
+ const { request, abortRequest } = createAjaxRequest({
+ url: '/manualimport',
+ data: payload
+ });
+
+ abortCurrentFetchRequest = abortRequest;
+
+ request.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ request.fail((xhr) => {
+ if (xhr.aborted) {
+ return;
+ }
+
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ },
+
+ [SAVE_INTERACTIVE_IMPORT_ITEM]: function(getState, payload, dispatch) {
+ if (abortCurrentRequest) {
+ abortCurrentRequest();
+ }
+
+ dispatch(batchActions([
+ ...currentIds.map((id) => updateItem({
+ section,
+ id,
+ isReprocessing: false,
+ updateOnly: true
+ })),
+ ...payload.ids.map((id) => updateItem({
+ section,
+ id,
+ isReprocessing: true,
+ updateOnly: true
+ }))
+ ]));
+
+ const items = getState()[section].items;
+
+ const requestPayload = payload.ids.map((id) => {
+ const item = items.find((i) => i.id === id);
+
+ return {
+ id,
+ path: item.path,
+ artistId: item.artist ? item.artist.id : undefined,
+ albumId: item.album ? item.album.id : undefined,
+ albumReleaseId: item.albumReleaseId ? item.albumReleaseId : undefined,
+ trackIds: (item.tracks || []).map((e) => e.id),
+ quality: item.quality,
+ releaseGroup: item.releaseGroup,
+ indexerFlags: item.indexerFlags,
+ downloadId: item.downloadId,
+ additionalFile: item.additionalFile,
+ replaceExistingFiles: item.replaceExistingFiles,
+ disableReleaseSwitching: item.disableReleaseSwitching
+ };
+ });
+
+ const { request, abortRequest } = createAjaxRequest({
+ method: 'POST',
+ url: '/manualimport',
+ contentType: 'application/json',
+ data: JSON.stringify(requestPayload)
+ });
+
+ abortCurrentRequest = abortRequest;
+ currentIds = payload.ids;
+
+ request.done((data) => {
+ dispatch(batchActions(
+ data.map((item) => updateItem({
+ section,
+ ...item,
+ isReprocessing: false,
+ updateOnly: true
+ }))
+ ));
+ });
+
+ request.fail((xhr) => {
+ if (xhr.aborted) {
+ return;
+ }
+
+ dispatch(batchActions(
+ payload.ids.map((id) => updateItem({
+ section,
+ id,
+ isReprocessing: false,
+ updateOnly: true
+ }))
+ ));
+ });
+ },
+
+ [FETCH_INTERACTIVE_IMPORT_TRACKFILES]: createFetchHandler(trackFilesSection, '/trackFile')
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [UPDATE_INTERACTIVE_IMPORT_ITEM]: (state, { payload }) => {
+ const id = payload.id;
+ const newState = Object.assign({}, state);
+ const items = newState.items;
+ const index = items.findIndex((item) => item.id === id);
+ const item = Object.assign({}, items[index], payload);
+
+ newState.items = [...items];
+ newState.items.splice(index, 1, item);
+
+ return newState;
+ },
+
+ [UPDATE_INTERACTIVE_IMPORT_ITEMS]: (state, { payload }) => {
+ const ids = payload.ids;
+ const newState = Object.assign({}, state);
+ const items = [...newState.items];
+
+ ids.forEach((id) => {
+ const index = items.findIndex((item) => item.id === id);
+ const item = Object.assign({}, items[index], payload);
+
+ items.splice(index, 1, item);
+ });
+
+ newState.items = items;
+
+ return newState;
+ },
+
+ [ADD_RECENT_FOLDER]: function(state, { payload }) {
+ const folder = payload.folder;
+ const recentFolder = { folder, lastUsed: moment().toISOString() };
+ const recentFolders = [...state.recentFolders];
+ const index = recentFolders.findIndex((r) => r.folder === folder);
+
+ if (index > -1) {
+ recentFolders.splice(index, 1);
+ }
+
+ recentFolders.push(recentFolder);
+
+ const sliceIndex = Math.max(recentFolders.length - MAXIMUM_RECENT_FOLDERS, 0);
+
+ return Object.assign({}, state, { recentFolders: recentFolders.slice(sliceIndex) });
+ },
+
+ [REMOVE_RECENT_FOLDER]: function(state, { payload }) {
+ const folder = payload.folder;
+ const recentFolders = [...state.recentFolders];
+ const index = recentFolders.findIndex((r) => r.folder === folder);
+
+ recentFolders.splice(index, 1);
+
+ return Object.assign({}, state, { recentFolders });
+ },
+
+ [CLEAR_INTERACTIVE_IMPORT]: function(state) {
+ const newState = {
+ ...defaultState,
+ recentFolders: state.recentFolders,
+ importMode: state.importMode
+ };
+
+ return newState;
+ },
+
+ [SET_INTERACTIVE_IMPORT_SORT]: createSetClientSideCollectionSortReducer(section),
+
+ [SET_INTERACTIVE_IMPORT_MODE]: function(state, { payload }) {
+ return Object.assign({}, state, { importMode: payload.importMode });
+ },
+
+ [CLEAR_INTERACTIVE_IMPORT_TRACKFILES]: (state) => {
+ return updateSectionState(state, trackFilesSection, {
+ ...defaultState.trackFiles
+ });
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/oAuthActions.js b/frontend/src/Store/Actions/oAuthActions.js
new file mode 100644
index 000000000..8e3ad3636
--- /dev/null
+++ b/frontend/src/Store/Actions/oAuthActions.js
@@ -0,0 +1,206 @@
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import { set } from 'Store/Actions/baseActions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import requestAction from 'Utilities/requestAction';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'oAuth';
+const callbackUrl = `${window.location.origin}${window.Lidarr.urlBase}/oauth.html`;
+
+//
+// State
+
+export const defaultState = {
+ authorizing: false,
+ result: null,
+ error: null
+};
+
+//
+// Actions Types
+
+export const START_OAUTH = 'oAuth/startOAuth';
+export const SET_OAUTH_VALUE = 'oAuth/setOAuthValue';
+export const RESET_OAUTH = 'oAuth/resetOAuth';
+
+//
+// Action Creators
+
+export const startOAuth = createThunk(START_OAUTH);
+export const setOAuthValue = createAction(SET_OAUTH_VALUE);
+export const resetOAuth = createAction(RESET_OAUTH);
+
+//
+// Helpers
+
+function showOAuthWindow(url, payload) {
+ const deferred = $.Deferred();
+ const selfWindow = window;
+
+ const newWindow = window.open(url);
+
+ if (
+ !newWindow ||
+ newWindow.closed ||
+ typeof newWindow.closed == 'undefined'
+ ) {
+
+ // A fake validation error to mimic a 400 response from the API.
+ const error = {
+ status: 400,
+ responseJSON: [
+ {
+ propertyName: payload.name,
+ errorMessage: 'Pop-ups are being blocked by your browser'
+ }
+ ]
+ };
+
+ return deferred.reject(error).promise();
+ }
+
+ selfWindow.onCompleteOauth = function(query, onComplete) {
+ delete selfWindow.onCompleteOauth;
+
+ const queryParams = {};
+ const splitQuery = query.substring(1).split('&');
+
+ splitQuery.forEach((param) => {
+ if (param) {
+ const paramSplit = param.split('=');
+
+ queryParams[paramSplit[0]] = paramSplit[1];
+ }
+ });
+
+ onComplete();
+ deferred.resolve(queryParams);
+ };
+
+ return deferred.promise();
+}
+
+function executeIntermediateRequest(payload, ajaxOptions) {
+ return createAjaxRequest(ajaxOptions).request.then((data) => {
+ return requestAction({
+ action: 'continueOAuth',
+ queryParams: {
+ ...data,
+ callbackUrl
+ },
+ ...payload
+ });
+ });
+}
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [START_OAUTH]: function(getState, payload, dispatch) {
+ const {
+ name,
+ section: actionSection,
+ ...otherPayload
+ } = payload;
+
+ const actionPayload = {
+ action: 'startOAuth',
+ queryParams: { callbackUrl },
+ ...otherPayload
+ };
+
+ dispatch(setOAuthValue({
+ authorizing: true
+ }));
+
+ let startResponse = {};
+
+ const promise = requestAction(actionPayload)
+ .then((response) => {
+ startResponse = response;
+
+ if (response.oauthUrl) {
+ return showOAuthWindow(response.oauthUrl, payload);
+ }
+
+ return executeIntermediateRequest(otherPayload, response).then((intermediateResponse) => {
+ startResponse = intermediateResponse;
+
+ return showOAuthWindow(intermediateResponse.oauthUrl, payload);
+ });
+ })
+ .then((queryParams) => {
+ return requestAction({
+ action: 'getOAuthToken',
+ queryParams: {
+ ...startResponse,
+ ...queryParams
+ },
+ ...otherPayload
+ });
+ })
+ .then((response) => {
+ dispatch(setOAuthValue({
+ authorizing: false,
+ result: response,
+ error: null
+ }));
+ });
+
+ promise.done(() => {
+ // Clear any previously set save error.
+ dispatch(set({
+ section: actionSection,
+ saveError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ const actions = [
+ setOAuthValue({
+ authorizing: false,
+ result: null,
+ error: xhr
+ })
+ ];
+
+ if (xhr.status === 400) {
+ // Set a save error so the UI can display validation errors to the user.
+ actions.splice(0, 0, set({
+ section: actionSection,
+ saveError: xhr
+ }));
+ }
+
+ dispatch(batchActions(actions));
+ });
+ }
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_OAUTH_VALUE]: function(state, { payload }) {
+ const newState = Object.assign(getSectionState(state, section), payload);
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [RESET_OAUTH]: function(state) {
+ return updateSectionState(state, section, defaultState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/organizePreviewActions.js b/frontend/src/Store/Actions/organizePreviewActions.js
new file mode 100644
index 000000000..78f943f32
--- /dev/null
+++ b/frontend/src/Store/Actions/organizePreviewActions.js
@@ -0,0 +1,51 @@
+import { createAction } from 'redux-actions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'organizePreview';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+};
+
+//
+// Actions Types
+
+export const FETCH_ORGANIZE_PREVIEW = 'organizePreview/fetchOrganizePreview';
+export const CLEAR_ORGANIZE_PREVIEW = 'organizePreview/clearOrganizePreview';
+
+//
+// Action Creators
+
+export const fetchOrganizePreview = createThunk(FETCH_ORGANIZE_PREVIEW);
+export const clearOrganizePreview = createAction(CLEAR_ORGANIZE_PREVIEW);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_ORGANIZE_PREVIEW]: createFetchHandler('organizePreview', '/rename')
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_ORGANIZE_PREVIEW]: (state) => {
+ return Object.assign({}, state, defaultState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/parseActions.ts b/frontend/src/Store/Actions/parseActions.ts
new file mode 100644
index 000000000..d4b6e9bcb
--- /dev/null
+++ b/frontend/src/Store/Actions/parseActions.ts
@@ -0,0 +1,111 @@
+import { Dispatch } from 'redux';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import AppState from 'App/State/AppState';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { set, update } from './baseActions';
+import createHandleActions from './Creators/createHandleActions';
+import createClearReducer from './Creators/Reducers/createClearReducer';
+
+interface FetchPayload {
+ title: string;
+}
+
+//
+// Variables
+
+export const section = 'parse';
+let parseTimeout: number | null = null;
+let abortCurrentRequest: (() => void) | null = null;
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ item: {},
+};
+
+//
+// Actions Types
+
+export const FETCH = 'parse/fetch';
+export const CLEAR = 'parse/clear';
+
+//
+// Action Creators
+
+export const fetch = createThunk(FETCH);
+export const clear = createAction(CLEAR);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH]: function (
+ _getState: () => AppState,
+ payload: FetchPayload,
+ dispatch: Dispatch
+ ) {
+ if (parseTimeout) {
+ clearTimeout(parseTimeout);
+ }
+
+ parseTimeout = window.setTimeout(async () => {
+ dispatch(set({ section, isFetching: true }));
+
+ if (abortCurrentRequest) {
+ abortCurrentRequest();
+ }
+
+ const { request, abortRequest } = createAjaxRequest({
+ url: '/parse',
+ data: {
+ title: payload.title,
+ },
+ });
+
+ try {
+ const data = await request;
+
+ dispatch(
+ batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null,
+ }),
+ ])
+ );
+ } catch (error) {
+ dispatch(
+ set({
+ section,
+ isAdding: false,
+ isAdded: false,
+ addError: error,
+ })
+ );
+ }
+
+ abortCurrentRequest = abortRequest;
+ }, 300);
+ },
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions(
+ {
+ [CLEAR]: createClearReducer(section, defaultState),
+ },
+ defaultState,
+ section
+);
diff --git a/frontend/src/Store/Actions/pathActions.js b/frontend/src/Store/Actions/pathActions.js
new file mode 100644
index 000000000..1cb7a15a2
--- /dev/null
+++ b/frontend/src/Store/Actions/pathActions.js
@@ -0,0 +1,112 @@
+import { createAction } from 'redux-actions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { set } from './baseActions';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'paths';
+
+//
+// State
+
+export const defaultState = {
+ currentPath: '',
+ isPopulated: false,
+ isFetching: false,
+ error: null,
+ directories: [],
+ files: [],
+ parent: null
+};
+
+//
+// Actions Types
+
+export const FETCH_PATHS = 'paths/fetchPaths';
+export const UPDATE_PATHS = 'paths/updatePaths';
+export const CLEAR_PATHS = 'paths/clearPaths';
+
+//
+// Action Creators
+
+export const fetchPaths = createThunk(FETCH_PATHS);
+export const updatePaths = createAction(UPDATE_PATHS);
+export const clearPaths = createAction(CLEAR_PATHS);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_PATHS]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ const {
+ path,
+ allowFoldersWithoutTrailingSlashes = false,
+ includeFiles = false
+ } = payload;
+
+ const promise = createAjaxRequest({
+ url: '/filesystem',
+ data: {
+ path,
+ allowFoldersWithoutTrailingSlashes,
+ includeFiles
+ }
+ }).request;
+
+ promise.done((data) => {
+ dispatch(updatePaths({ path, ...data }));
+
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ }
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [UPDATE_PATHS]: (state, { payload }) => {
+ const newState = Object.assign({}, state);
+
+ newState.currentPath = payload.path;
+ newState.directories = payload.directories;
+ newState.files = payload.files;
+ newState.parent = payload.parent;
+
+ return newState;
+ },
+
+ [CLEAR_PATHS]: (state, { payload }) => {
+ const newState = Object.assign({}, state);
+
+ newState.path = '';
+ newState.directories = [];
+ newState.files = [];
+ newState.parent = '';
+
+ return newState;
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/providerOptionActions.js b/frontend/src/Store/Actions/providerOptionActions.js
new file mode 100644
index 000000000..4dc38a98f
--- /dev/null
+++ b/frontend/src/Store/Actions/providerOptionActions.js
@@ -0,0 +1,111 @@
+import _ from 'lodash';
+import { createAction } from 'redux-actions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import requestAction from 'Utilities/requestAction';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import { set } from './baseActions';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'providerOptions';
+
+const lastActions = {};
+let lastActionId = 0;
+
+//
+// State
+
+export const defaultState = {
+ items: [],
+ isFetching: false,
+ isPopulated: false,
+ error: false
+};
+
+//
+// Actions Types
+
+export const FETCH_OPTIONS = 'providers/fetchOptions';
+export const CLEAR_OPTIONS = 'providers/clearOptions';
+
+//
+// Action Creators
+
+export const fetchOptions = createThunk(FETCH_OPTIONS);
+export const clearOptions = createAction(CLEAR_OPTIONS);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_OPTIONS]: function(getState, payload, dispatch) {
+ const subsection = `${section}.${payload.section}`;
+
+ if (lastActions[payload.section] && _.isEqual(payload, lastActions[payload.section].payload)) {
+ return;
+ }
+
+ const actionId = ++lastActionId;
+
+ lastActions[payload.section] = {
+ actionId,
+ payload
+ };
+
+ dispatch(set({
+ section: subsection,
+ isFetching: true
+ }));
+
+ const promise = requestAction(payload);
+
+ promise.done((data) => {
+ if (lastActions[payload.section]) {
+ if (lastActions[payload.section].actionId === actionId) {
+ lastActions[payload.section] = null;
+ }
+
+ dispatch(set({
+ section: subsection,
+ isFetching: false,
+ isPopulated: true,
+ error: null,
+ items: data.options || []
+ }));
+ }
+ });
+
+ promise.fail((xhr) => {
+ if (lastActions[payload.section]) {
+ if (lastActions[payload.section].actionId === actionId) {
+ lastActions[payload.section] = null;
+ }
+
+ dispatch(set({
+ section: subsection,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ }
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_OPTIONS]: function(state, { payload }) {
+ const subsection = `${section}.${payload.section}`;
+
+ lastActions[payload.section] = null;
+
+ return updateSectionState(state, subsection, defaultState);
+ }
+
+}, {}, section);
diff --git a/frontend/src/Store/Actions/queueActions.js b/frontend/src/Store/Actions/queueActions.js
new file mode 100644
index 000000000..4bf200a5c
--- /dev/null
+++ b/frontend/src/Store/Actions/queueActions.js
@@ -0,0 +1,517 @@
+import _ from 'lodash';
+import React from 'react';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import Icon from 'Components/Icon';
+import { filterBuilderTypes, filterBuilderValueTypes, icons, sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import translate from 'Utilities/String/translate';
+import { set, updateItem } from './baseActions';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+import createClearReducer from './Creators/Reducers/createClearReducer';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+
+//
+// Variables
+
+export const section = 'queue';
+const status = `${section}.status`;
+const details = `${section}.details`;
+const paged = `${section}.paged`;
+
+//
+// State
+
+export const defaultState = {
+ options: {
+ includeUnknownArtistItems: true
+ },
+
+ status: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ item: {}
+ },
+
+ details: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ params: {}
+ },
+
+ paged: {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 20,
+ sortKey: 'timeleft',
+ sortDirection: sortDirections.ASCENDING,
+ error: null,
+ items: [],
+ isGrabbing: false,
+ isRemoving: false,
+
+ columns: [
+ {
+ name: 'status',
+ columnLabel: () => translate('Status'),
+ isSortable: true,
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'artists.sortName',
+ label: () => translate('Artist'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'albums.title',
+ label: () => translate('AlbumTitle'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'albums.releaseDate',
+ label: () => translate('AlbumReleaseDate'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'quality',
+ label: () => translate('Quality'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'customFormats',
+ label: 'Formats',
+ isSortable: false,
+ isVisible: true
+ },
+ {
+ name: 'customFormatScore',
+ columnLabel: () => translate('CustomFormatScore'),
+ label: React.createElement(Icon, {
+ name: icons.SCORE,
+ title: () => translate('CustomFormatScore')
+ }),
+ isVisible: false
+ },
+ {
+ name: 'protocol',
+ label: () => translate('Protocol'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'indexer',
+ label: () => translate('Indexer'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'downloadClient',
+ label: () => translate('DownloadClient'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'title',
+ label: () => translate('ReleaseTitle'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'size',
+ label: () => translate('Size'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'outputPath',
+ label: () => translate('OutputPath'),
+ isSortable: false,
+ isVisible: false
+ },
+ {
+ name: 'estimatedCompletionTime',
+ label: () => translate('TimeLeft'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'added',
+ label: () => translate('Added'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'progress',
+ label: () => translate('Progress'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ columnLabel: () => translate('Actions'),
+ isVisible: true,
+ isModifiable: false
+ }
+ ],
+
+ selectedFilterKey: 'all',
+
+ filters: [
+ {
+ key: 'all',
+ label: 'All',
+ filters: []
+ }
+ ],
+
+ filterBuilderProps: [
+ {
+ name: 'artistIds',
+ label: () => translate('Artist'),
+ type: filterBuilderTypes.EQUAL,
+ valueType: filterBuilderValueTypes.ARTIST
+ },
+ {
+ name: 'quality',
+ label: () => translate('Quality'),
+ type: filterBuilderTypes.EQUAL,
+ valueType: filterBuilderValueTypes.QUALITY
+ },
+ {
+ name: 'protocol',
+ label: () => translate('Protocol'),
+ type: filterBuilderTypes.EQUAL,
+ valueType: filterBuilderValueTypes.PROTOCOL
+ }
+ ]
+ }
+};
+
+export const persistState = [
+ 'queue.options',
+ 'queue.paged.pageSize',
+ 'queue.paged.sortKey',
+ 'queue.paged.sortDirection',
+ 'queue.paged.columns',
+ 'queue.paged.selectedFilterKey'
+];
+
+//
+// Helpers
+
+function fetchDataAugmenter(getState, payload, data) {
+ data.includeUnknownArtistItems = getState().queue.options.includeUnknownArtistItems;
+}
+
+//
+// Actions Types
+
+export const FETCH_QUEUE_STATUS = 'queue/fetchQueueStatus';
+
+export const FETCH_QUEUE_DETAILS = 'queue/fetchQueueDetails';
+export const CLEAR_QUEUE_DETAILS = 'queue/clearQueueDetails';
+
+export const FETCH_QUEUE = 'queue/fetchQueue';
+export const GOTO_FIRST_QUEUE_PAGE = 'queue/gotoQueueFirstPage';
+export const GOTO_PREVIOUS_QUEUE_PAGE = 'queue/gotoQueuePreviousPage';
+export const GOTO_NEXT_QUEUE_PAGE = 'queue/gotoQueueNextPage';
+export const GOTO_LAST_QUEUE_PAGE = 'queue/gotoQueueLastPage';
+export const GOTO_QUEUE_PAGE = 'queue/gotoQueuePage';
+export const SET_QUEUE_SORT = 'queue/setQueueSort';
+export const SET_QUEUE_FILTER = 'queue/setQueueFilter';
+export const SET_QUEUE_TABLE_OPTION = 'queue/setQueueTableOption';
+export const SET_QUEUE_OPTION = 'queue/setQueueOption';
+export const CLEAR_QUEUE = 'queue/clearQueue';
+
+export const GRAB_QUEUE_ITEM = 'queue/grabQueueItem';
+export const GRAB_QUEUE_ITEMS = 'queue/grabQueueItems';
+export const REMOVE_QUEUE_ITEM = 'queue/removeQueueItem';
+export const REMOVE_QUEUE_ITEMS = 'queue/removeQueueItems';
+
+//
+// Action Creators
+
+export const fetchQueueStatus = createThunk(FETCH_QUEUE_STATUS);
+
+export const fetchQueueDetails = createThunk(FETCH_QUEUE_DETAILS);
+export const clearQueueDetails = createAction(CLEAR_QUEUE_DETAILS);
+
+export const fetchQueue = createThunk(FETCH_QUEUE);
+export const gotoQueueFirstPage = createThunk(GOTO_FIRST_QUEUE_PAGE);
+export const gotoQueuePreviousPage = createThunk(GOTO_PREVIOUS_QUEUE_PAGE);
+export const gotoQueueNextPage = createThunk(GOTO_NEXT_QUEUE_PAGE);
+export const gotoQueueLastPage = createThunk(GOTO_LAST_QUEUE_PAGE);
+export const gotoQueuePage = createThunk(GOTO_QUEUE_PAGE);
+export const setQueueSort = createThunk(SET_QUEUE_SORT);
+export const setQueueFilter = createThunk(SET_QUEUE_FILTER);
+export const setQueueTableOption = createAction(SET_QUEUE_TABLE_OPTION);
+export const setQueueOption = createAction(SET_QUEUE_OPTION);
+export const clearQueue = createAction(CLEAR_QUEUE);
+
+export const grabQueueItem = createThunk(GRAB_QUEUE_ITEM);
+export const grabQueueItems = createThunk(GRAB_QUEUE_ITEMS);
+export const removeQueueItem = createThunk(REMOVE_QUEUE_ITEM);
+export const removeQueueItems = createThunk(REMOVE_QUEUE_ITEMS);
+
+//
+// Helpers
+
+const fetchQueueDetailsHelper = createFetchHandler(details, '/queue/details');
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_QUEUE_STATUS]: createFetchHandler(status, '/queue/status'),
+
+ [FETCH_QUEUE_DETAILS]: function(getState, payload, dispatch) {
+ let params = payload;
+
+ // If the payload params are empty try to get params from state.
+
+ if (params && !_.isEmpty(params)) {
+ dispatch(set({ section: details, params }));
+ } else {
+ params = getState().queue.details.params;
+ }
+
+ // Ensure there are params before trying to fetch the queue
+ // so we don't make a bad request to the server.
+
+ if (params && !_.isEmpty(params)) {
+ fetchQueueDetailsHelper(getState, params, dispatch);
+ }
+ },
+
+ ...createServerSideCollectionHandlers(
+ paged,
+ '/queue',
+ fetchQueue,
+ {
+ [serverSideCollectionHandlers.FETCH]: FETCH_QUEUE,
+ [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_QUEUE_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_QUEUE_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_QUEUE_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_QUEUE_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_QUEUE_PAGE,
+ [serverSideCollectionHandlers.SORT]: SET_QUEUE_SORT,
+ [serverSideCollectionHandlers.FILTER]: SET_QUEUE_FILTER
+ },
+ fetchDataAugmenter
+ ),
+
+ [GRAB_QUEUE_ITEM]: function(getState, payload, dispatch) {
+ const id = payload.id;
+
+ dispatch(updateItem({ section: paged, id, isGrabbing: true }));
+
+ const promise = createAjaxRequest({
+ url: `/queue/grab/${id}`,
+ method: 'POST'
+ }).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ fetchQueue(),
+
+ set({
+ section: paged,
+ isGrabbing: false,
+ grabError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ section: paged,
+ id,
+ isGrabbing: false,
+ grabError: xhr
+ }));
+ });
+ },
+
+ [GRAB_QUEUE_ITEMS]: function(getState, payload, dispatch) {
+ const ids = payload.ids;
+
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section: paged,
+ id,
+ isGrabbing: true
+ });
+ }),
+
+ set({
+ section: paged,
+ isGrabbing: true
+ })
+ ]));
+
+ const promise = createAjaxRequest({
+ url: '/queue/grab/bulk',
+ method: 'POST',
+ dataType: 'json',
+ data: JSON.stringify(payload)
+ }).request;
+
+ promise.done((data) => {
+ dispatch(fetchQueue());
+
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section: paged,
+ id,
+ isGrabbing: false,
+ grabError: null
+ });
+ }),
+
+ set({
+ section: paged,
+ isGrabbing: false,
+ grabError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section: paged,
+ id,
+ isGrabbing: false,
+ grabError: null
+ });
+ }),
+
+ set({ section: paged, isGrabbing: false })
+ ]));
+ });
+ },
+
+ [REMOVE_QUEUE_ITEM]: function(getState, payload, dispatch) {
+ const {
+ id,
+ removeFromClient,
+ blocklist,
+ skipRedownload,
+ changeCategory
+ } = payload;
+
+ dispatch(updateItem({ section: paged, id, isRemoving: true }));
+
+ const promise = createAjaxRequest({
+ url: `/queue/${id}?removeFromClient=${removeFromClient}&blocklist=${blocklist}&skipRedownload=${skipRedownload}&changeCategory=${changeCategory}`,
+ method: 'DELETE'
+ }).request;
+
+ promise.done((data) => {
+ dispatch(fetchQueue());
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({ section: paged, id, isRemoving: false }));
+ });
+ },
+
+ [REMOVE_QUEUE_ITEMS]: function(getState, payload, dispatch) {
+ const {
+ ids,
+ removeFromClient,
+ blocklist,
+ skipRedownload,
+ changeCategory
+ } = payload;
+
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section: paged,
+ id,
+ isRemoving: true
+ });
+ }),
+
+ set({ section: paged, isRemoving: true })
+ ]));
+
+ const promise = createAjaxRequest({
+ url: `/queue/bulk?removeFromClient=${removeFromClient}&blocklist=${blocklist}&skipRedownload=${skipRedownload}&changeCategory=${changeCategory}`,
+ method: 'DELETE',
+ dataType: 'json',
+ contentType: 'application/json',
+ data: JSON.stringify({ ids })
+ }).request;
+
+ promise.done((data) => {
+ // Don't use batchActions with thunks
+ dispatch(fetchQueue());
+
+ dispatch(set({ section: paged, isRemoving: false }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section: paged,
+ id,
+ isRemoving: false
+ });
+ }),
+
+ set({ section: paged, isRemoving: false })
+ ]));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_QUEUE_DETAILS]: createClearReducer(details, defaultState.details),
+
+ [SET_QUEUE_TABLE_OPTION]: createSetTableOptionReducer(paged),
+
+ [SET_QUEUE_OPTION]: function(state, { payload }) {
+ const queueOptions = state.options;
+
+ return {
+ ...state,
+ options: {
+ ...queueOptions,
+ ...payload
+ }
+ };
+ },
+
+ [CLEAR_QUEUE]: createClearReducer(paged, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ totalPages: 0,
+ totalRecords: 0
+ })
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/releaseActions.js b/frontend/src/Store/Actions/releaseActions.js
new file mode 100644
index 000000000..c4955c915
--- /dev/null
+++ b/frontend/src/Store/Actions/releaseActions.js
@@ -0,0 +1,346 @@
+import { createAction } from 'redux-actions';
+import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import translate from 'Utilities/String/translate';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+
+//
+// Variables
+
+export const section = 'releases';
+export const albumSection = 'releases.album';
+export const artistSection = 'releases.artist';
+
+let abortCurrentRequest = null;
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ sortKey: 'releaseWeight',
+ sortDirection: sortDirections.ASCENDING,
+ sortPredicates: {
+ age: function(item, direction) {
+ return item.ageMinutes;
+ },
+ peers: function(item, direction) {
+ const seeders = item.seeders || 0;
+ const leechers = item.leechers || 0;
+
+ return seeders * 1000000 + leechers;
+ },
+ rejections: function(item, direction) {
+ const rejections = item.rejections;
+ const releaseWeight = item.releaseWeight;
+
+ if (rejections.length !== 0) {
+ return releaseWeight + 1000000;
+ }
+
+ return releaseWeight;
+ }
+ },
+
+ filters: [
+ {
+ key: 'all',
+ label: () => translate('All'),
+ filters: []
+ },
+ {
+ key: 'discography-pack',
+ label: () => translate('Discography'),
+ filters: [
+ {
+ key: 'discography',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'not-discography-pack',
+ label: () => translate('NotDiscography'),
+ filters: [
+ {
+ key: 'discography',
+ value: false,
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+ ],
+
+ filterPredicates: {
+ quality: function(item, value, type) {
+ const qualityId = item.quality.quality.id;
+
+ if (type === filterTypes.EQUAL) {
+ return qualityId === value;
+ }
+
+ if (type === filterTypes.NOT_EQUAL) {
+ return qualityId !== value;
+ }
+
+ // Default to false
+ return false;
+ },
+
+ rejectionCount: function(item, value, type) {
+ const rejectionCount = item.rejections.length;
+
+ switch (type) {
+ case filterTypes.EQUAL:
+ return rejectionCount === value;
+
+ case filterTypes.GREATER_THAN:
+ return rejectionCount > value;
+
+ case filterTypes.GREATER_THAN_OR_EQUAL:
+ return rejectionCount >= value;
+
+ case filterTypes.LESS_THAN:
+ return rejectionCount < value;
+
+ case filterTypes.LESS_THAN_OR_EQUAL:
+ return rejectionCount <= value;
+
+ case filterTypes.NOT_EQUAL:
+ return rejectionCount !== value;
+
+ default:
+ return false;
+ }
+ },
+
+ peers: function(item, value, type) {
+ const seeders = item.seeders || 0;
+ const leechers = item.leechers || 0;
+ const peers = seeders + leechers;
+
+ switch (type) {
+ case filterTypes.EQUAL:
+ return peers === value;
+
+ case filterTypes.GREATER_THAN:
+ return peers > value;
+
+ case filterTypes.GREATER_THAN_OR_EQUAL:
+ return peers >= value;
+
+ case filterTypes.LESS_THAN:
+ return peers < value;
+
+ case filterTypes.LESS_THAN_OR_EQUAL:
+ return peers <= value;
+
+ case filterTypes.NOT_EQUAL:
+ return peers !== value;
+
+ default:
+ return false;
+ }
+ }
+ },
+
+ filterBuilderProps: [
+ {
+ name: 'title',
+ label: () => translate('Title'),
+ type: filterBuilderTypes.STRING
+ },
+ {
+ name: 'age',
+ label: () => translate('Age'),
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'protocol',
+ label: () => translate('Protocol'),
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.PROTOCOL
+ },
+ {
+ name: 'indexerId',
+ label: () => translate('Indexer'),
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.INDEXER
+ },
+ {
+ name: 'size',
+ label: () => translate('Size'),
+ type: filterBuilderTypes.NUMBER,
+ valueType: filterBuilderValueTypes.BYTES
+ },
+ {
+ name: 'seeders',
+ label: () => translate('Seeders'),
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'leechers',
+ label: () => translate('Peers'),
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'quality',
+ label: () => translate('Quality'),
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.QUALITY
+ },
+ {
+ name: 'customFormatScore',
+ label: () => translate('CustomFormatScore'),
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'rejectionCount',
+ label: () => translate('RejectionCount'),
+ type: filterBuilderTypes.NUMBER
+ }
+ ],
+
+ album: {
+ selectedFilterKey: 'all'
+ },
+
+ artist: {
+ selectedFilterKey: 'discography-pack'
+ }
+};
+
+export const persistState = [
+ 'releases.album.selectedFilterKey',
+ 'releases.album.customFilters',
+ 'releases.artist.selectedFilterKey',
+ 'releases.artist.customFilters'
+];
+
+//
+// Actions Types
+
+export const FETCH_RELEASES = 'releases/fetchReleases';
+export const CANCEL_FETCH_RELEASES = 'releases/cancelFetchReleases';
+export const SET_RELEASES_SORT = 'releases/setReleasesSort';
+export const CLEAR_RELEASES = 'releases/clearReleases';
+export const GRAB_RELEASE = 'releases/grabRelease';
+export const UPDATE_RELEASE = 'releases/updateRelease';
+export const SET_ALBUM_RELEASES_FILTER = 'releases/setAlbumReleasesFilter';
+export const SET_ARTIST_RELEASES_FILTER = 'releases/setArtistReleasesFilter';
+
+//
+// Action Creators
+
+export const fetchReleases = createThunk(FETCH_RELEASES);
+export const cancelFetchReleases = createThunk(CANCEL_FETCH_RELEASES);
+export const setReleasesSort = createAction(SET_RELEASES_SORT);
+export const clearReleases = createAction(CLEAR_RELEASES);
+export const grabRelease = createThunk(GRAB_RELEASE);
+export const updateRelease = createAction(UPDATE_RELEASE);
+export const setAlbumReleasesFilter = createAction(SET_ALBUM_RELEASES_FILTER);
+export const setArtistReleasesFilter = createAction(SET_ARTIST_RELEASES_FILTER);
+
+//
+// Helpers
+
+const fetchReleasesHelper = createFetchHandler(section, '/release');
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_RELEASES]: function(getState, payload, dispatch) {
+ const abortRequest = fetchReleasesHelper(getState, payload, dispatch);
+
+ abortCurrentRequest = abortRequest;
+ },
+
+ [CANCEL_FETCH_RELEASES]: function(getState, payload, dispatch) {
+ if (abortCurrentRequest) {
+ abortCurrentRequest = abortCurrentRequest();
+ }
+ },
+
+ [GRAB_RELEASE]: function(getState, payload, dispatch) {
+ const guid = payload.guid;
+
+ dispatch(updateRelease({ guid, isGrabbing: true }));
+
+ const promise = createAjaxRequest({
+ url: '/release',
+ method: 'POST',
+ dataType: 'json',
+ contentType: 'application/json',
+ data: JSON.stringify(payload)
+ }).request;
+
+ promise.done((data) => {
+ dispatch(updateRelease({
+ guid,
+ isGrabbing: false,
+ isGrabbed: true,
+ grabError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ const grabError = xhr.responseJSON && xhr.responseJSON.message || 'Failed to add to download queue';
+
+ dispatch(updateRelease({
+ guid,
+ isGrabbing: false,
+ isGrabbed: false,
+ grabError
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_RELEASES]: (state) => {
+ const {
+ album,
+ artist,
+ ...otherDefaultState
+ } = defaultState;
+
+ return Object.assign({}, state, otherDefaultState);
+ },
+
+ [UPDATE_RELEASE]: (state, { payload }) => {
+ const guid = payload.guid;
+ const newState = Object.assign({}, state);
+ const items = newState.items;
+ const index = items.findIndex((item) => item.guid === guid);
+
+ // Don't try to update if there isnt a matching item (the user closed the modal)
+
+ if (index >= 0) {
+ const item = Object.assign({}, items[index], payload);
+
+ newState.items = [...items];
+ newState.items.splice(index, 1, item);
+ }
+
+ return newState;
+ },
+
+ [SET_RELEASES_SORT]: createSetClientSideCollectionSortReducer(section),
+ [SET_ALBUM_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(albumSection),
+ [SET_ARTIST_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(artistSection)
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/retagPreviewActions.js b/frontend/src/Store/Actions/retagPreviewActions.js
new file mode 100644
index 000000000..73632fcf8
--- /dev/null
+++ b/frontend/src/Store/Actions/retagPreviewActions.js
@@ -0,0 +1,51 @@
+import { createAction } from 'redux-actions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'retagPreview';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+};
+
+//
+// Actions Types
+
+export const FETCH_RETAG_PREVIEW = 'retagPreview/fetchRetagPreview';
+export const CLEAR_RETAG_PREVIEW = 'retagPreview/clearRetagPreview';
+
+//
+// Action Creators
+
+export const fetchRetagPreview = createThunk(FETCH_RETAG_PREVIEW);
+export const clearRetagPreview = createAction(CLEAR_RETAG_PREVIEW);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_RETAG_PREVIEW]: createFetchHandler('retagPreview', '/retag')
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_RETAG_PREVIEW]: (state) => {
+ return Object.assign({}, state, defaultState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/searchActions.js b/frontend/src/Store/Actions/searchActions.js
new file mode 100644
index 000000000..c2af6f47b
--- /dev/null
+++ b/frontend/src/Store/Actions/searchActions.js
@@ -0,0 +1,217 @@
+import _ from 'lodash';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import getNewAlbum from 'Utilities/Album/getNewAlbum';
+import getNewArtist from 'Utilities/Artist/getNewArtist';
+import monitorNewItemsOptions from 'Utilities/Artist/monitorNewItemsOptions';
+import monitorOptions from 'Utilities/Artist/monitorOptions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import { set, update, updateItem } from './baseActions';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'search';
+let abortCurrentRequest = null;
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isAdding: false,
+ isAdded: false,
+ addError: null,
+ items: [],
+
+ defaults: {
+ rootFolderPath: '',
+ monitor: monitorOptions[0].key,
+ monitorNewItems: monitorNewItemsOptions[0].key,
+ qualityProfileId: 0,
+ metadataProfileId: 0,
+ searchForMissingAlbums: false,
+ searchForNewAlbum: false,
+ tags: []
+ }
+};
+
+export const persistState = [
+ 'search.defaults'
+];
+
+//
+// Actions Types
+
+export const GET_SEARCH_RESULTS = 'search/getSearchResults';
+export const ADD_ARTIST = 'search/addArtist';
+export const ADD_ALBUM = 'search/addAlbum';
+export const CLEAR_SEARCH_RESULTS = 'search/clearSearchResults';
+export const SET_ADD_DEFAULT = 'search/setAddDefault';
+
+//
+// Action Creators
+
+export const getSearchResults = createThunk(GET_SEARCH_RESULTS);
+export const addArtist = createThunk(ADD_ARTIST);
+export const addAlbum = createThunk(ADD_ALBUM);
+export const clearSearchResults = createAction(CLEAR_SEARCH_RESULTS);
+export const setAddDefault = createAction(SET_ADD_DEFAULT);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [GET_SEARCH_RESULTS]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ if (abortCurrentRequest) {
+ abortCurrentRequest();
+ }
+
+ const { request, abortRequest } = createAjaxRequest({
+ url: '/search',
+ data: {
+ term: payload.term
+ }
+ });
+
+ abortCurrentRequest = abortRequest;
+
+ request.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ request.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr.aborted ? null : xhr
+ }));
+ });
+ },
+
+ [ADD_ARTIST]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isAdding: true }));
+
+ const foreignArtistId = payload.foreignArtistId;
+ const items = getState().search.items;
+ const itemToAdd = _.find(items, { foreignId: foreignArtistId });
+ const newArtist = getNewArtist(_.cloneDeep(itemToAdd.artist), payload);
+
+ const promise = createAjaxRequest({
+ url: '/artist',
+ method: 'POST',
+ dataType: 'json',
+ contentType: 'application/json',
+ data: JSON.stringify(newArtist)
+ }).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ updateItem({ section: 'artist', ...data }),
+
+ set({
+ section,
+ isAdding: false,
+ isAdded: true,
+ addError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isAdding: false,
+ isAdded: false,
+ addError: xhr
+ }));
+ });
+ },
+
+ [ADD_ALBUM]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isAdding: true }));
+
+ const foreignAlbumId = payload.foreignAlbumId;
+ const items = getState().search.items;
+ const itemToAdd = _.find(items, { foreignId: foreignAlbumId });
+ const newAlbum = getNewAlbum(_.cloneDeep(itemToAdd.album), payload);
+
+ const promise = createAjaxRequest({
+ url: '/album',
+ method: 'POST',
+ contentType: 'application/json',
+ data: JSON.stringify(newAlbum)
+ }).request;
+
+ promise.done((data) => {
+ data.releases = itemToAdd.album.releases;
+ itemToAdd.album = data;
+ dispatch(batchActions([
+ updateItem({ section: 'artist', ...data.artist }),
+ updateItem({ section, ...itemToAdd }),
+
+ set({
+ section,
+ isAdding: false,
+ isAdded: true,
+ addError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isAdding: false,
+ isAdded: false,
+ addError: xhr
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_ADD_DEFAULT]: function(state, { payload }) {
+ const newState = getSectionState(state, section);
+
+ newState.defaults = {
+ ...newState.defaults,
+ ...payload
+ };
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [CLEAR_SEARCH_RESULTS]: function(state) {
+ const {
+ defaults,
+ ...otherDefaultState
+ } = defaultState;
+
+ return Object.assign({}, state, otherDefaultState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js
new file mode 100644
index 000000000..54b059083
--- /dev/null
+++ b/frontend/src/Store/Actions/settingsActions.js
@@ -0,0 +1,183 @@
+import { createAction } from 'redux-actions';
+import { handleThunks } from 'Store/thunks';
+import createHandleActions from './Creators/createHandleActions';
+import autoTaggings from './Settings/autoTaggings';
+import autoTaggingSpecifications from './Settings/autoTaggingSpecifications';
+import customFormats from './Settings/customFormats';
+import customFormatSpecifications from './Settings/customFormatSpecifications';
+import delayProfiles from './Settings/delayProfiles';
+import downloadClientOptions from './Settings/downloadClientOptions';
+import downloadClients from './Settings/downloadClients';
+import general from './Settings/general';
+import importListExclusions from './Settings/importListExclusions';
+import importLists from './Settings/importLists';
+import indexerFlags from './Settings/indexerFlags';
+import indexerOptions from './Settings/indexerOptions';
+import indexers from './Settings/indexers';
+import languages from './Settings/languages';
+import mediaManagement from './Settings/mediaManagement';
+import metadata from './Settings/metadata';
+import metadataProfiles from './Settings/metadataProfiles';
+import metadataProvider from './Settings/metadataProvider';
+import naming from './Settings/naming';
+import namingExamples from './Settings/namingExamples';
+import notifications from './Settings/notifications';
+import qualityDefinitions from './Settings/qualityDefinitions';
+import qualityProfiles from './Settings/qualityProfiles';
+import releaseProfiles from './Settings/releaseProfiles';
+import remotePathMappings from './Settings/remotePathMappings';
+import rootFolders from './Settings/rootFolders';
+import ui from './Settings/ui';
+
+export * from './Settings/autoTaggingSpecifications';
+export * from './Settings/autoTaggings';
+export * from './Settings/customFormatSpecifications.js';
+export * from './Settings/customFormats';
+export * from './Settings/delayProfiles';
+export * from './Settings/downloadClients';
+export * from './Settings/downloadClientOptions';
+export * from './Settings/general';
+export * from './Settings/importLists';
+export * from './Settings/importListExclusions';
+export * from './Settings/indexerFlags';
+export * from './Settings/indexerOptions';
+export * from './Settings/indexers';
+export * from './Settings/languages';
+export * from './Settings/metadataProfiles';
+export * from './Settings/mediaManagement';
+export * from './Settings/metadata';
+export * from './Settings/metadataProvider';
+export * from './Settings/naming';
+export * from './Settings/namingExamples';
+export * from './Settings/notifications';
+export * from './Settings/qualityDefinitions';
+export * from './Settings/qualityProfiles';
+export * from './Settings/releaseProfiles';
+export * from './Settings/remotePathMappings';
+export * from './Settings/rootFolders';
+export * from './Settings/ui';
+
+//
+// Variables
+
+export const section = 'settings';
+
+//
+// State
+
+export const defaultState = {
+ advancedSettings: false,
+ autoTaggingSpecifications: autoTaggingSpecifications.defaultState,
+ autoTaggings: autoTaggings.defaultState,
+ customFormatSpecifications: customFormatSpecifications.defaultState,
+ customFormats: customFormats.defaultState,
+ delayProfiles: delayProfiles.defaultState,
+ downloadClients: downloadClients.defaultState,
+ downloadClientOptions: downloadClientOptions.defaultState,
+ general: general.defaultState,
+ indexerFlags: indexerFlags.defaultState,
+ indexerOptions: indexerOptions.defaultState,
+ indexers: indexers.defaultState,
+ importLists: importLists.defaultState,
+ importListExclusions: importListExclusions.defaultState,
+ languages: languages.defaultState,
+ metadataProfiles: metadataProfiles.defaultState,
+ mediaManagement: mediaManagement.defaultState,
+ metadata: metadata.defaultState,
+ metadataProvider: metadataProvider.defaultState,
+ naming: naming.defaultState,
+ namingExamples: namingExamples.defaultState,
+ notifications: notifications.defaultState,
+ qualityDefinitions: qualityDefinitions.defaultState,
+ qualityProfiles: qualityProfiles.defaultState,
+ releaseProfiles: releaseProfiles.defaultState,
+ remotePathMappings: remotePathMappings.defaultState,
+ rootFolders: rootFolders.defaultState,
+ ui: ui.defaultState
+};
+
+export const persistState = [
+ 'settings.advancedSettings'
+];
+
+//
+// Actions Types
+
+export const TOGGLE_ADVANCED_SETTINGS = 'settings/toggleAdvancedSettings';
+
+//
+// Action Creators
+
+export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ ...autoTaggingSpecifications.actionHandlers,
+ ...autoTaggings.actionHandlers,
+ ...customFormatSpecifications.actionHandlers,
+ ...customFormats.actionHandlers,
+ ...delayProfiles.actionHandlers,
+ ...downloadClients.actionHandlers,
+ ...downloadClientOptions.actionHandlers,
+ ...general.actionHandlers,
+ ...indexerFlags.actionHandlers,
+ ...indexerOptions.actionHandlers,
+ ...indexers.actionHandlers,
+ ...importLists.actionHandlers,
+ ...importListExclusions.actionHandlers,
+ ...languages.actionHandlers,
+ ...metadataProfiles.actionHandlers,
+ ...mediaManagement.actionHandlers,
+ ...metadata.actionHandlers,
+ ...metadataProvider.actionHandlers,
+ ...naming.actionHandlers,
+ ...namingExamples.actionHandlers,
+ ...notifications.actionHandlers,
+ ...qualityDefinitions.actionHandlers,
+ ...qualityProfiles.actionHandlers,
+ ...releaseProfiles.actionHandlers,
+ ...remotePathMappings.actionHandlers,
+ ...rootFolders.actionHandlers,
+ ...ui.actionHandlers
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [TOGGLE_ADVANCED_SETTINGS]: (state, { payload }) => {
+ return Object.assign({}, state, { advancedSettings: !state.advancedSettings });
+ },
+
+ ...autoTaggingSpecifications.reducers,
+ ...autoTaggings.reducers,
+ ...customFormatSpecifications.reducers,
+ ...customFormats.reducers,
+ ...delayProfiles.reducers,
+ ...downloadClients.reducers,
+ ...downloadClientOptions.reducers,
+ ...general.reducers,
+ ...indexerFlags.reducers,
+ ...indexerOptions.reducers,
+ ...indexers.reducers,
+ ...importLists.reducers,
+ ...importListExclusions.reducers,
+ ...languages.reducers,
+ ...metadataProfiles.reducers,
+ ...mediaManagement.reducers,
+ ...metadata.reducers,
+ ...metadataProvider.reducers,
+ ...naming.reducers,
+ ...namingExamples.reducers,
+ ...notifications.reducers,
+ ...qualityDefinitions.reducers,
+ ...qualityProfiles.reducers,
+ ...releaseProfiles.reducers,
+ ...remotePathMappings.reducers,
+ ...rootFolders.reducers,
+ ...ui.reducers
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/systemActions.js b/frontend/src/Store/Actions/systemActions.js
new file mode 100644
index 000000000..92360b589
--- /dev/null
+++ b/frontend/src/Store/Actions/systemActions.js
@@ -0,0 +1,395 @@
+import { createAction } from 'redux-actions';
+import { filterTypes, sortDirections } from 'Helpers/Props';
+import { setAppValue } from 'Store/Actions/appActions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import translate from 'Utilities/String/translate';
+import { pingServer } from './appActions';
+import { set } from './baseActions';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+import createClearReducer from './Creators/Reducers/createClearReducer';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+
+//
+// Variables
+
+export const section = 'system';
+const backupsSection = 'system.backups';
+
+//
+// State
+
+export const defaultState = {
+ status: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ item: {}
+ },
+
+ health: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ diskSpace: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ tasks: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ backups: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isRestoring: false,
+ restoreError: null,
+ isDeleting: false,
+ deleteError: null,
+ items: []
+ },
+
+ updates: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ logs: {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 50,
+ sortKey: 'time',
+ sortDirection: sortDirections.DESCENDING,
+ error: null,
+ items: [],
+
+ columns: [
+ {
+ name: 'level',
+ columnLabel: () => translate('Level'),
+ isSortable: false,
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'time',
+ label: () => translate('Time'),
+ isSortable: true,
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'logger',
+ label: () => translate('Component'),
+ isSortable: false,
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'message',
+ label: () => translate('Message'),
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'actions',
+ columnLabel: () => translate('Actions'),
+ isSortable: true,
+ isVisible: true,
+ isModifiable: false
+ }
+ ],
+
+ selectedFilterKey: 'all',
+
+ filters: [
+ {
+ key: 'all',
+ label: () => translate('All'),
+ filters: []
+ },
+ {
+ key: 'info',
+ label: () => translate('Info'),
+ filters: [
+ {
+ key: 'level',
+ value: 'info',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'warn',
+ label: () => translate('Warn'),
+ filters: [
+ {
+ key: 'level',
+ value: 'warn',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'error',
+ label: () => translate('Error'),
+ filters: [
+ {
+ key: 'level',
+ value: 'error',
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+ ]
+ },
+
+ logFiles: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ updateLogFiles: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ }
+};
+
+export const persistState = [
+ 'system.logs.pageSize',
+ 'system.logs.sortKey',
+ 'system.logs.sortDirection',
+ 'system.logs.selectedFilterKey'
+];
+
+//
+// Actions Types
+
+export const FETCH_STATUS = 'system/status/fetchStatus';
+export const FETCH_HEALTH = 'system/health/fetchHealth';
+export const FETCH_DISK_SPACE = 'system/diskSpace/fetchDiskSPace';
+
+export const FETCH_TASK = 'system/tasks/fetchTask';
+export const FETCH_TASKS = 'system/tasks/fetchTasks';
+
+export const FETCH_BACKUPS = 'system/backups/fetchBackups';
+export const RESTORE_BACKUP = 'system/backups/restoreBackup';
+export const CLEAR_RESTORE_BACKUP = 'system/backups/clearRestoreBackup';
+export const DELETE_BACKUP = 'system/backups/deleteBackup';
+
+export const FETCH_UPDATES = 'system/updates/fetchUpdates';
+
+export const FETCH_LOGS = 'system/logs/fetchLogs';
+export const GOTO_FIRST_LOGS_PAGE = 'system/logs/gotoLogsFirstPage';
+export const GOTO_PREVIOUS_LOGS_PAGE = 'system/logs/gotoLogsPreviousPage';
+export const GOTO_NEXT_LOGS_PAGE = 'system/logs/gotoLogsNextPage';
+export const GOTO_LAST_LOGS_PAGE = 'system/logs/gotoLogsLastPage';
+export const GOTO_LOGS_PAGE = 'system/logs/gotoLogsPage';
+export const SET_LOGS_SORT = 'system/logs/setLogsSort';
+export const SET_LOGS_FILTER = 'system/logs/setLogsFilter';
+export const SET_LOGS_TABLE_OPTION = 'system/logs/setLogsTableOption';
+export const CLEAR_LOGS_TABLE = 'system/logs/clearLogsTable';
+
+export const FETCH_LOG_FILES = 'system/logFiles/fetchLogFiles';
+export const FETCH_UPDATE_LOG_FILES = 'system/updateLogFiles/fetchUpdateLogFiles';
+
+export const RESTART = 'system/restart';
+export const SHUTDOWN = 'system/shutdown';
+
+//
+// Action Creators
+
+export const fetchStatus = createThunk(FETCH_STATUS);
+export const fetchHealth = createThunk(FETCH_HEALTH);
+export const fetchDiskSpace = createThunk(FETCH_DISK_SPACE);
+
+export const fetchTask = createThunk(FETCH_TASK);
+export const fetchTasks = createThunk(FETCH_TASKS);
+
+export const fetchBackups = createThunk(FETCH_BACKUPS);
+export const restoreBackup = createThunk(RESTORE_BACKUP);
+export const clearRestoreBackup = createAction(CLEAR_RESTORE_BACKUP);
+export const deleteBackup = createThunk(DELETE_BACKUP);
+
+export const fetchUpdates = createThunk(FETCH_UPDATES);
+
+export const fetchLogs = createThunk(FETCH_LOGS);
+export const gotoLogsFirstPage = createThunk(GOTO_FIRST_LOGS_PAGE);
+export const gotoLogsPreviousPage = createThunk(GOTO_PREVIOUS_LOGS_PAGE);
+export const gotoLogsNextPage = createThunk(GOTO_NEXT_LOGS_PAGE);
+export const gotoLogsLastPage = createThunk(GOTO_LAST_LOGS_PAGE);
+export const gotoLogsPage = createThunk(GOTO_LOGS_PAGE);
+export const setLogsSort = createThunk(SET_LOGS_SORT);
+export const setLogsFilter = createThunk(SET_LOGS_FILTER);
+export const setLogsTableOption = createAction(SET_LOGS_TABLE_OPTION);
+export const clearLogsTable = createAction(CLEAR_LOGS_TABLE);
+
+export const fetchLogFiles = createThunk(FETCH_LOG_FILES);
+export const fetchUpdateLogFiles = createThunk(FETCH_UPDATE_LOG_FILES);
+
+export const restart = createThunk(RESTART);
+export const shutdown = createThunk(SHUTDOWN);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_STATUS]: createFetchHandler('system.status', '/system/status'),
+ [FETCH_HEALTH]: createFetchHandler('system.health', '/health'),
+ [FETCH_DISK_SPACE]: createFetchHandler('system.diskSpace', '/diskspace'),
+ [FETCH_TASK]: createFetchHandler('system.tasks', '/system/task'),
+ [FETCH_TASKS]: createFetchHandler('system.tasks', '/system/task'),
+
+ [FETCH_BACKUPS]: createFetchHandler(backupsSection, '/system/backup'),
+
+ [RESTORE_BACKUP]: function(getState, payload, dispatch) {
+ const {
+ id,
+ file
+ } = payload;
+
+ dispatch(set({
+ section: backupsSection,
+ isRestoring: true
+ }));
+
+ let ajaxOptions = null;
+
+ if (id) {
+ ajaxOptions = {
+ url: `/system/backup/restore/${id}`,
+ method: 'POST',
+ contentType: 'application/json',
+ dataType: 'json',
+ data: JSON.stringify({
+ id
+ })
+ };
+ } else if (file) {
+ const formData = new FormData();
+ formData.append('restore', file);
+
+ ajaxOptions = {
+ url: '/system/backup/restore/upload',
+ method: 'POST',
+ processData: false,
+ contentType: false,
+ data: formData
+ };
+ } else {
+ dispatch(set({
+ section: backupsSection,
+ isRestoring: false,
+ restoreError: 'Error restoring backup'
+ }));
+ }
+
+ const promise = createAjaxRequest(ajaxOptions).request;
+
+ promise.done((data) => {
+ dispatch(set({
+ section: backupsSection,
+ isRestoring: false,
+ restoreError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section: backupsSection,
+ isRestoring: false,
+ restoreError: xhr
+ }));
+ });
+ },
+
+ [DELETE_BACKUP]: createRemoveItemHandler(backupsSection, '/system/backup'),
+
+ [FETCH_UPDATES]: createFetchHandler('system.updates', '/update'),
+ [FETCH_LOG_FILES]: createFetchHandler('system.logFiles', '/log/file'),
+ [FETCH_UPDATE_LOG_FILES]: createFetchHandler('system.updateLogFiles', '/log/file/update'),
+
+ ...createServerSideCollectionHandlers(
+ 'system.logs',
+ '/log',
+ fetchLogs,
+ {
+ [serverSideCollectionHandlers.FETCH]: FETCH_LOGS,
+ [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_LOGS_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_LOGS_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_LOGS_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_LOGS_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_LOGS_PAGE,
+ [serverSideCollectionHandlers.SORT]: SET_LOGS_SORT,
+ [serverSideCollectionHandlers.FILTER]: SET_LOGS_FILTER
+ }
+ ),
+
+ [RESTART]: function(getState, payload, dispatch) {
+ const promise = createAjaxRequest({
+ url: '/system/restart',
+ method: 'POST'
+ }).request;
+
+ promise.done(() => {
+ dispatch(setAppValue({ isRestarting: true }));
+ dispatch(pingServer());
+ });
+ },
+
+ [SHUTDOWN]: function() {
+ createAjaxRequest({
+ url: '/system/shutdown',
+ method: 'POST'
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_RESTORE_BACKUP]: function(state, { payload }) {
+ return {
+ ...state,
+ backups: {
+ ...state.backups,
+ isRestoring: false,
+ restoreError: null
+ }
+ };
+ },
+
+ [SET_LOGS_TABLE_OPTION]: createSetTableOptionReducer('logs'),
+
+ [CLEAR_LOGS_TABLE]: createClearReducer(section, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ totalPages: 0,
+ totalRecords: 0
+ })
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/tagActions.js b/frontend/src/Store/Actions/tagActions.js
new file mode 100644
index 000000000..6800b1d58
--- /dev/null
+++ b/frontend/src/Store/Actions/tagActions.js
@@ -0,0 +1,76 @@
+import { createThunk, handleThunks } from 'Store/thunks';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { update } from './baseActions';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+
+//
+// Variables
+
+export const section = 'tags';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+
+ details: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ }
+};
+
+//
+// Actions Types
+
+export const FETCH_TAGS = 'tags/fetchTags';
+export const ADD_TAG = 'tags/addTag';
+export const DELETE_TAG = 'tags/deleteTag';
+export const FETCH_TAG_DETAILS = 'tags/fetchTagDetails';
+
+//
+// Action Creators
+
+export const fetchTags = createThunk(FETCH_TAGS);
+export const addTag = createThunk(ADD_TAG);
+export const deleteTag = createThunk(DELETE_TAG);
+export const fetchTagDetails = createThunk(FETCH_TAG_DETAILS);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_TAGS]: createFetchHandler(section, '/tag'),
+
+ [ADD_TAG]: function(getState, payload, dispatch) {
+ const promise = createAjaxRequest({
+ url: '/tag',
+ method: 'POST',
+ data: JSON.stringify(payload.tag),
+ dataType: 'json'
+ }).request;
+
+ promise.done((data) => {
+ const tags = getState().tags.items.slice();
+ tags.push(data);
+
+ dispatch(update({ section, data: tags }));
+ payload.onTagCreated(data);
+ });
+ },
+
+ [DELETE_TAG]: createRemoveItemHandler(section, '/tag'),
+ [FETCH_TAG_DETAILS]: createFetchHandler('tags.details', '/tag/detail')
+
+});
+
+//
+// Reducers
+export const reducers = createHandleActions({}, defaultState, section);
diff --git a/frontend/src/Store/Actions/trackActions.js b/frontend/src/Store/Actions/trackActions.js
new file mode 100644
index 000000000..a71388c88
--- /dev/null
+++ b/frontend/src/Store/Actions/trackActions.js
@@ -0,0 +1,151 @@
+import React from 'react';
+import { createAction } from 'redux-actions';
+import Icon from 'Components/Icon';
+import { icons, sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import translate from 'Utilities/String/translate';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+
+//
+// Variables
+
+export const section = 'tracks';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ sortKey: 'mediumNumber',
+ sortDirection: sortDirections.ASCENDING,
+ secondarySortKey: 'absoluteTrackNumber',
+ secondarySortDirection: sortDirections.ASCENDING,
+ items: [],
+
+ columns: [
+ {
+ name: 'medium',
+ label: () => translate('Medium'),
+ isVisible: false
+ },
+ {
+ name: 'absoluteTrackNumber',
+ label: () => translate('Track'),
+ isVisible: true
+ },
+ {
+ name: 'title',
+ label: () => translate('Title'),
+ isVisible: true
+ },
+ {
+ name: 'path',
+ label: () => translate('Path'),
+ isVisible: false
+ },
+ {
+ name: 'duration',
+ label: () => translate('Duration'),
+ isVisible: true
+ },
+ {
+ name: 'audioInfo',
+ label: () => translate('AudioInfo'),
+ isVisible: true
+ },
+ {
+ name: 'size',
+ label: () => translate('Size'),
+ isVisible: false
+ },
+ {
+ name: 'customFormats',
+ label: 'Formats',
+ isVisible: false
+ },
+ {
+ name: 'customFormatScore',
+ columnLabel: () => translate('CustomFormatScore'),
+ label: React.createElement(Icon, {
+ name: icons.SCORE,
+ title: () => translate('CustomFormatScore')
+ }),
+ isVisible: false
+ },
+ {
+ name: 'indexerFlags',
+ columnLabel: () => translate('IndexerFlags'),
+ label: React.createElement(Icon, {
+ name: icons.FLAG,
+ title: () => translate('IndexerFlags')
+ }),
+ isVisible: false
+ },
+ {
+ name: 'status',
+ label: () => translate('Status'),
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ columnLabel: () => translate('Actions'),
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+};
+
+export const persistState = [
+ 'tracks.sortKey',
+ 'tracks.sortDirection',
+ 'tracks.columns'
+];
+
+//
+// Actions Types
+
+export const FETCH_TRACKS = 'tracks/fetchTracks';
+export const SET_TRACKS_SORT = 'tracks/setTracksSort';
+export const SET_TRACKS_TABLE_OPTION = 'tracks/setTracksTableOption';
+export const CLEAR_TRACKS = 'tracks/clearTracks';
+
+//
+// Action Creators
+
+export const fetchTracks = createThunk(FETCH_TRACKS);
+export const setTracksSort = createAction(SET_TRACKS_SORT);
+export const setTracksTableOption = createAction(SET_TRACKS_TABLE_OPTION);
+export const clearTracks = createAction(CLEAR_TRACKS);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_TRACKS]: createFetchHandler(section, '/track')
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_TRACKS_TABLE_OPTION]: createSetTableOptionReducer(section),
+
+ [FETCH_TRACKS]: (state) => {
+ return Object.assign({}, state, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ });
+ },
+
+ [SET_TRACKS_SORT]: createSetClientSideCollectionSortReducer(section)
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/trackFileActions.js b/frontend/src/Store/Actions/trackFileActions.js
new file mode 100644
index 000000000..f4af704ee
--- /dev/null
+++ b/frontend/src/Store/Actions/trackFileActions.js
@@ -0,0 +1,275 @@
+import _ from 'lodash';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import albumEntities from 'Album/albumEntities';
+import { sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import translate from 'Utilities/String/translate';
+import { removeItem, set, updateItem } from './baseActions';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import createClearReducer from './Creators/Reducers/createClearReducer';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+
+//
+// Variables
+
+export const section = 'trackFiles';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ sortKey: 'path',
+ sortDirection: sortDirections.ASCENDING,
+
+ error: null,
+ isDeleting: false,
+ deleteError: null,
+ isSaving: false,
+ saveError: null,
+ items: [],
+
+ sortPredicates: {
+ quality: function(item, direction) {
+ return item.quality ? item.qualityWeight : 0;
+ }
+ },
+
+ columns: [
+ {
+ name: 'select',
+ columnLabel: 'Select',
+ isSortable: false,
+ isVisible: true,
+ isModifiable: false,
+ isHidden: true
+ },
+ {
+ name: 'path',
+ label: () => translate('Path'),
+ isSortable: true,
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'size',
+ label: () => translate('Size'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'dateAdded',
+ label: () => translate('DateAdded'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'quality',
+ label: () => translate('Quality'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ columnLabel: () => translate('Actions'),
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+};
+
+export const persistState = [
+ 'trackFiles.sortKey',
+ 'trackFiles.sortDirection'
+];
+
+//
+// Actions Types
+
+export const FETCH_TRACK_FILES = 'trackFiles/fetchTrackFiles';
+export const DELETE_TRACK_FILE = 'trackFiles/deleteTrackFile';
+export const DELETE_TRACK_FILES = 'trackFiles/deleteTrackFiles';
+export const UPDATE_TRACK_FILES = 'trackFiles/updateTrackFiles';
+export const SET_TRACK_FILES_SORT = 'trackFiles/setTrackFilesSort';
+export const SET_TRACK_FILES_TABLE_OPTION = 'trackFiles/setTrackFilesTableOption';
+export const CLEAR_TRACK_FILES = 'trackFiles/clearTrackFiles';
+
+//
+// Action Creators
+
+export const fetchTrackFiles = createThunk(FETCH_TRACK_FILES);
+export const deleteTrackFile = createThunk(DELETE_TRACK_FILE);
+export const deleteTrackFiles = createThunk(DELETE_TRACK_FILES);
+export const updateTrackFiles = createThunk(UPDATE_TRACK_FILES);
+export const setTrackFilesSort = createAction(SET_TRACK_FILES_SORT);
+export const setTrackFilesTableOption = createAction(SET_TRACK_FILES_TABLE_OPTION);
+export const clearTrackFiles = createAction(CLEAR_TRACK_FILES);
+
+//
+// Helpers
+
+const deleteTrackFileHelper = createRemoveItemHandler(section, '/trackFile');
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_TRACK_FILES]: createFetchHandler(section, '/trackFile'),
+
+ [DELETE_TRACK_FILE]: function(getState, payload, dispatch) {
+ const {
+ id: trackFileId,
+ albumEntity = albumEntities.ALBUMS
+ } = payload;
+
+ const albumSection = _.last(albumEntity.split('.'));
+ const deletePromise = deleteTrackFileHelper(getState, payload, dispatch);
+
+ deletePromise.done(() => {
+ const albums = getState().albums.items;
+ const tracksWithRemovedFiles = _.filter(albums, { trackFileId });
+
+ dispatch(batchActions([
+ ...tracksWithRemovedFiles.map((track) => {
+ return updateItem({
+ section: albumSection,
+ ...track,
+ trackFileId: 0,
+ hasFile: false
+ });
+ })
+ ]));
+ });
+ },
+
+ [DELETE_TRACK_FILES]: function(getState, payload, dispatch) {
+ const {
+ trackFileIds
+ } = payload;
+
+ dispatch(set({ section, isDeleting: true }));
+
+ const promise = createAjaxRequest({
+ url: '/trackFile/bulk',
+ method: 'DELETE',
+ dataType: 'json',
+ data: JSON.stringify({ trackFileIds })
+ }).request;
+
+ promise.done(() => {
+ const tracks = getState().tracks.items;
+ const tracksWithRemovedFiles = trackFileIds.reduce((acc, trackFileId) => {
+ acc.push(..._.filter(tracks, { trackFileId }));
+
+ return acc;
+ }, []);
+
+ dispatch(batchActions([
+ ...trackFileIds.map((id) => {
+ return removeItem({ section, id });
+ }),
+
+ ...tracksWithRemovedFiles.map((track) => {
+ return updateItem({
+ section: 'tracks',
+ ...track,
+ trackFileId: 0,
+ hasFile: false
+ });
+ }),
+
+ set({
+ section,
+ isDeleting: false,
+ deleteError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isDeleting: false,
+ deleteError: xhr
+ }));
+ });
+ },
+
+ [UPDATE_TRACK_FILES]: function(getState, payload, dispatch) {
+ const {
+ trackFileIds,
+ quality
+ } = payload;
+
+ dispatch(set({ section, isSaving: true }));
+
+ const requestData = {
+ trackFileIds
+ };
+
+ if (quality) {
+ requestData.quality = quality;
+ }
+
+ const promise = createAjaxRequest({
+ url: '/trackFile/editor',
+ method: 'PUT',
+ dataType: 'json',
+ data: JSON.stringify(requestData)
+ }).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ ...trackFileIds.map((id) => {
+ const props = {};
+
+ const trackFile = data.find((file) => file.id === id);
+
+ props.qualityCutoffNotMet = trackFile.qualityCutoffNotMet;
+
+ if (quality) {
+ props.quality = quality;
+ }
+
+ return updateItem({ section, id, ...props });
+ }),
+
+ set({
+ section,
+ isSaving: false,
+ saveError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+ [SET_TRACK_FILES_SORT]: createSetClientSideCollectionSortReducer(section),
+ [SET_TRACK_FILES_TABLE_OPTION]: createSetTableOptionReducer(section),
+
+ [CLEAR_TRACK_FILES]: createClearReducer(section, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ })
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/wantedActions.js b/frontend/src/Store/Actions/wantedActions.js
new file mode 100644
index 000000000..61d6f7752
--- /dev/null
+++ b/frontend/src/Store/Actions/wantedActions.js
@@ -0,0 +1,329 @@
+import { createAction } from 'redux-actions';
+import { filterTypes, sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import translate from 'Utilities/String/translate';
+import createBatchToggleAlbumMonitoredHandler from './Creators/createBatchToggleAlbumMonitoredHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+import createClearReducer from './Creators/Reducers/createClearReducer';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+
+//
+// Variables
+
+export const section = 'wanted';
+
+//
+// State
+
+export const defaultState = {
+ missing: {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 20,
+ sortKey: 'releaseDate',
+ sortDirection: sortDirections.DESCENDING,
+ error: null,
+ items: [],
+
+ columns: [
+ {
+ name: 'artists.sortName',
+ label: () => translate('ArtistName'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'albums.title',
+ label: () => translate('AlbumTitle'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'albumType',
+ label: () => translate('AlbumType'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'releaseDate',
+ label: () => translate('ReleaseDate'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'albums.lastSearchTime',
+ label: () => translate('LastSearched'),
+ isSortable: true,
+ isVisible: false
+ },
+ // {
+ // name: 'status',
+ // label: 'Status',
+ // isVisible: true
+ // },
+ {
+ name: 'actions',
+ columnLabel: () => translate('Actions'),
+ isVisible: true,
+ isModifiable: false
+ }
+ ],
+
+ selectedFilterKey: 'monitored',
+
+ filters: [
+ {
+ key: 'monitored',
+ label: () => translate('Monitored'),
+ filters: [
+ {
+ key: 'monitored',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'unmonitored',
+ label: () => translate('Unmonitored'),
+ filters: [
+ {
+ key: 'monitored',
+ value: false,
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+ ]
+ },
+
+ cutoffUnmet: {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 20,
+ sortKey: 'releaseDate',
+ sortDirection: sortDirections.DESCENDING,
+ items: [],
+
+ columns: [
+ {
+ name: 'artists.sortName',
+ label: () => translate('ArtistName'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'albums.title',
+ label: () => translate('AlbumTitle'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'albumType',
+ label: () => translate('AlbumType'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'releaseDate',
+ label: () => translate('ReleaseDate'),
+ isSortable: true,
+ isVisible: true
+ },
+ // {
+ // name: 'status',
+ // label: 'Status',
+ // isVisible: true
+ // },
+ {
+ name: 'albums.lastSearchTime',
+ label: () => translate('LastSearched'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'actions',
+ columnLabel: () => translate('Actions'),
+ isVisible: true,
+ isModifiable: false
+ }
+ ],
+
+ selectedFilterKey: 'monitored',
+
+ filters: [
+ {
+ key: 'monitored',
+ label: () => translate('Monitored'),
+ filters: [
+ {
+ key: 'monitored',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'unmonitored',
+ label: () => translate('Unmonitored'),
+ filters: [
+ {
+ key: 'monitored',
+ value: false,
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+ ]
+ }
+};
+
+export const persistState = [
+ 'wanted.missing.pageSize',
+ 'wanted.missing.sortKey',
+ 'wanted.missing.sortDirection',
+ 'wanted.missing.selectedFilterKey',
+ 'wanted.missing.columns',
+ 'wanted.cutoffUnmet.pageSize',
+ 'wanted.cutoffUnmet.sortKey',
+ 'wanted.cutoffUnmet.sortDirection',
+ 'wanted.cutoffUnmet.selectedFilterKey',
+ 'wanted.cutoffUnmet.columns'
+];
+
+//
+// Actions Types
+
+export const FETCH_MISSING = 'wanted/missing/fetchMissing';
+export const GOTO_FIRST_MISSING_PAGE = 'wanted/missing/gotoMissingFirstPage';
+export const GOTO_PREVIOUS_MISSING_PAGE = 'wanted/missing/gotoMissingPreviousPage';
+export const GOTO_NEXT_MISSING_PAGE = 'wanted/missing/gotoMissingNextPage';
+export const GOTO_LAST_MISSING_PAGE = 'wanted/missing/gotoMissingLastPage';
+export const GOTO_MISSING_PAGE = 'wanted/missing/gotoMissingPage';
+export const SET_MISSING_SORT = 'wanted/missing/setMissingSort';
+export const SET_MISSING_FILTER = 'wanted/missing/setMissingFilter';
+export const SET_MISSING_TABLE_OPTION = 'wanted/missing/setMissingTableOption';
+export const CLEAR_MISSING = 'wanted/missing/clearMissing';
+
+export const BATCH_TOGGLE_MISSING_ALBUMS = 'wanted/missing/batchToggleMissingAlbums';
+
+export const FETCH_CUTOFF_UNMET = 'wanted/cutoffUnmet/fetchCutoffUnmet';
+export const GOTO_FIRST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFirstPage';
+export const GOTO_PREVIOUS_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPreviousPage';
+export const GOTO_NEXT_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetNextPage';
+export const GOTO_LAST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFastPage';
+export const GOTO_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPage';
+export const SET_CUTOFF_UNMET_SORT = 'wanted/cutoffUnmet/setCutoffUnmetSort';
+export const SET_CUTOFF_UNMET_FILTER = 'wanted/cutoffUnmet/setCutoffUnmetFilter';
+export const SET_CUTOFF_UNMET_TABLE_OPTION = 'wanted/cutoffUnmet/setCutoffUnmetTableOption';
+export const CLEAR_CUTOFF_UNMET = 'wanted/cutoffUnmet/clearCutoffUnmet';
+
+export const BATCH_TOGGLE_CUTOFF_UNMET_ALBUMS = 'wanted/cutoffUnmet/batchToggleCutoffUnmetAlbums';
+
+//
+// Action Creators
+
+export const fetchMissing = createThunk(FETCH_MISSING);
+export const gotoMissingFirstPage = createThunk(GOTO_FIRST_MISSING_PAGE);
+export const gotoMissingPreviousPage = createThunk(GOTO_PREVIOUS_MISSING_PAGE);
+export const gotoMissingNextPage = createThunk(GOTO_NEXT_MISSING_PAGE);
+export const gotoMissingLastPage = createThunk(GOTO_LAST_MISSING_PAGE);
+export const gotoMissingPage = createThunk(GOTO_MISSING_PAGE);
+export const setMissingSort = createThunk(SET_MISSING_SORT);
+export const setMissingFilter = createThunk(SET_MISSING_FILTER);
+export const setMissingTableOption = createAction(SET_MISSING_TABLE_OPTION);
+export const clearMissing = createAction(CLEAR_MISSING);
+
+export const batchToggleMissingAlbums = createThunk(BATCH_TOGGLE_MISSING_ALBUMS);
+
+export const fetchCutoffUnmet = createThunk(FETCH_CUTOFF_UNMET);
+export const gotoCutoffUnmetFirstPage = createThunk(GOTO_FIRST_CUTOFF_UNMET_PAGE);
+export const gotoCutoffUnmetPreviousPage = createThunk(GOTO_PREVIOUS_CUTOFF_UNMET_PAGE);
+export const gotoCutoffUnmetNextPage = createThunk(GOTO_NEXT_CUTOFF_UNMET_PAGE);
+export const gotoCutoffUnmetLastPage = createThunk(GOTO_LAST_CUTOFF_UNMET_PAGE);
+export const gotoCutoffUnmetPage = createThunk(GOTO_CUTOFF_UNMET_PAGE);
+export const setCutoffUnmetSort = createThunk(SET_CUTOFF_UNMET_SORT);
+export const setCutoffUnmetFilter = createThunk(SET_CUTOFF_UNMET_FILTER);
+export const setCutoffUnmetTableOption = createAction(SET_CUTOFF_UNMET_TABLE_OPTION);
+export const clearCutoffUnmet = createAction(CLEAR_CUTOFF_UNMET);
+
+export const batchToggleCutoffUnmetAlbums = createThunk(BATCH_TOGGLE_CUTOFF_UNMET_ALBUMS);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ ...createServerSideCollectionHandlers(
+ 'wanted.missing',
+ '/wanted/missing',
+ fetchMissing,
+ {
+ [serverSideCollectionHandlers.FETCH]: FETCH_MISSING,
+ [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_MISSING_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_MISSING_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_MISSING_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_MISSING_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_MISSING_PAGE,
+ [serverSideCollectionHandlers.SORT]: SET_MISSING_SORT,
+ [serverSideCollectionHandlers.FILTER]: SET_MISSING_FILTER
+ }
+ ),
+
+ [BATCH_TOGGLE_MISSING_ALBUMS]: createBatchToggleAlbumMonitoredHandler('wanted.missing', fetchMissing),
+
+ ...createServerSideCollectionHandlers(
+ 'wanted.cutoffUnmet',
+ '/wanted/cutoff',
+ fetchCutoffUnmet,
+ {
+ [serverSideCollectionHandlers.FETCH]: FETCH_CUTOFF_UNMET,
+ [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.SORT]: SET_CUTOFF_UNMET_SORT,
+ [serverSideCollectionHandlers.FILTER]: SET_CUTOFF_UNMET_FILTER
+ }
+ ),
+
+ [BATCH_TOGGLE_CUTOFF_UNMET_ALBUMS]: createBatchToggleAlbumMonitoredHandler('wanted.cutoffUnmet', fetchCutoffUnmet)
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_MISSING_TABLE_OPTION]: createSetTableOptionReducer('wanted.missing'),
+ [SET_CUTOFF_UNMET_TABLE_OPTION]: createSetTableOptionReducer('wanted.cutoffUnmet'),
+
+ [CLEAR_MISSING]: createClearReducer(
+ 'wanted.missing',
+ {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ totalPages: 0,
+ totalRecords: 0
+ }
+ ),
+
+ [CLEAR_CUTOFF_UNMET]: createClearReducer(
+ 'wanted.cutoffUnmet',
+ {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ totalPages: 0,
+ totalRecords: 0
+ }
+ )
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Middleware/createPersistState.js b/frontend/src/Store/Middleware/createPersistState.js
new file mode 100644
index 000000000..57452d6a3
--- /dev/null
+++ b/frontend/src/Store/Middleware/createPersistState.js
@@ -0,0 +1,113 @@
+import _ from 'lodash';
+import persistState from 'redux-localstorage';
+import actions from 'Store/Actions';
+import migrate from 'Store/Migrators/migrate';
+
+const columnPaths = [];
+
+const paths = _.reduce([...actions], (acc, action) => {
+ if (action.persistState) {
+ action.persistState.forEach((path) => {
+ if (path.match(/\.columns$/)) {
+ columnPaths.push(path);
+ }
+
+ acc.push(path);
+ });
+ }
+
+ return acc;
+}, []);
+
+function mergeColumns(path, initialState, persistedState, computedState) {
+ const initialColumns = _.get(initialState, path);
+ const persistedColumns = _.get(persistedState, path);
+
+ if (!persistedColumns || !persistedColumns.length) {
+ return;
+ }
+
+ const columns = [];
+
+ // Add persisted columns in the same order they're currently in
+ // as long as they haven't been removed.
+
+ persistedColumns.forEach((persistedColumn) => {
+ const column = initialColumns.find((i) => i.name === persistedColumn.name);
+
+ if (column) {
+ const newColumn = {};
+
+ // We can't use a spread operator or Object.assign to clone the column
+ // or any accessors are lost and can break translations.
+ for (const prop of Object.keys(column)) {
+ Object.defineProperty(newColumn, prop, Object.getOwnPropertyDescriptor(column, prop));
+ }
+
+ newColumn.isVisible = persistedColumn.isVisible;
+
+ columns.push(newColumn);
+ }
+ });
+
+ // Add any columns added to the app in the initial position.
+ initialColumns.forEach((initialColumn, index) => {
+ const persistedColumnIndex = persistedColumns.findIndex((i) => i.name === initialColumn.name);
+ const column = Object.assign({}, initialColumn);
+
+ if (persistedColumnIndex === -1) {
+ columns.splice(index, 0, column);
+ }
+ });
+
+ // Set the columns in the persisted state
+ _.set(computedState, path, columns);
+}
+
+function slicer(paths_) {
+ return (state) => {
+ const subset = {};
+
+ paths_.forEach((path) => {
+ _.set(subset, path, _.get(state, path));
+ });
+
+ return subset;
+ };
+}
+
+function serialize(obj) {
+ return JSON.stringify(obj, null, 2);
+}
+
+function merge(initialState, persistedState) {
+ if (!persistedState) {
+ return initialState;
+ }
+
+ const computedState = {};
+
+ _.merge(computedState, initialState, persistedState);
+
+ columnPaths.forEach((columnPath) => {
+ mergeColumns(columnPath, initialState, persistedState, computedState);
+ });
+
+ return computedState;
+}
+
+const config = {
+ slicer,
+ serialize,
+ merge,
+ key: 'lidarr'
+};
+
+export default function createPersistState() {
+ // Migrate existing local storage before proceeding
+ const persistedState = JSON.parse(localStorage.getItem(config.key));
+ migrate(persistedState);
+ localStorage.setItem(config.key, serialize(persistedState));
+
+ return persistState(paths, config);
+}
diff --git a/frontend/src/Store/Middleware/createSentryMiddleware.js b/frontend/src/Store/Middleware/createSentryMiddleware.js
new file mode 100644
index 000000000..02706b8a1
--- /dev/null
+++ b/frontend/src/Store/Middleware/createSentryMiddleware.js
@@ -0,0 +1,125 @@
+import * as sentry from '@sentry/browser';
+import * as Integrations from '@sentry/integrations';
+import _ from 'lodash';
+import parseUrl from 'Utilities/String/parseUrl';
+
+const IgnoreErrors = [
+ // Innocuous browser errors
+ /ResizeObserver loop limit exceeded/,
+ /ResizeObserver loop completed with undelivered notifications/
+];
+
+function cleanseUrl(url) {
+ const properties = parseUrl(url);
+
+ return `${properties.pathname}${properties.search}`;
+}
+
+function shouldIgnoreException(s) {
+ return s && IgnoreErrors.find((pattern) => pattern.test(s));
+}
+
+function cleanseData(event, hint) {
+ const result = _.cloneDeep(event);
+
+ const error = hint && hint.originalException;
+
+ result.transaction = cleanseUrl(result.transaction);
+
+ if (result.exception) {
+ result.exception.values.forEach((exception) => {
+ const stacktrace = exception.stacktrace;
+
+ if (stacktrace) {
+ stacktrace.frames.forEach((frame) => {
+ frame.filename = cleanseUrl(frame.filename);
+ });
+ }
+ });
+ }
+
+ if (
+ error &&
+ error.message &&
+ shouldIgnoreException(error.message)
+ ) {
+ return null;
+ }
+
+ result.request.url = cleanseUrl(result.request.url);
+
+ return result;
+}
+
+function identity(stuff) {
+ return stuff;
+}
+
+function stripUrlBase(frame) {
+ if (frame.filename && window.Lidarr.urlBase) {
+ frame.filename = frame.filename.replace(window.Lidarr.urlBase, '');
+ }
+ return frame;
+}
+
+function createMiddleware() {
+ return (store) => (next) => (action) => {
+ try {
+ // Adds a breadcrumb for reporting later (if necessary).
+ sentry.addBreadcrumb({
+ category: 'redux',
+ message: action.type
+ });
+
+ return next(action);
+ } catch (err) {
+ console.error(`[sentry] Reporting error to Sentry: ${err}`);
+
+ // Send the report including breadcrumbs.
+ sentry.captureException(err, {
+ extra: {
+ action: identity(action),
+ state: identity(store.getState())
+ }
+ });
+ }
+ };
+}
+
+export default function createSentryMiddleware() {
+ const {
+ analytics,
+ branch,
+ version,
+ release,
+ userHash,
+ isProduction
+ } = window.Lidarr;
+
+ if (!analytics) {
+ return;
+ }
+
+ const dsn = isProduction ? 'https://9df0cbe9eedf4b8698e8bc94ec03c4ff@sentry.servarr.com/18' :
+ 'https://aaa261a8c3e542f0a52c07bed78e8d13@sentry.servarr.com/19';
+
+ sentry.init({
+ dsn,
+ environment: branch,
+ release,
+ sendDefaultPii: true,
+ beforeSend: cleanseData,
+ integrations: [
+ new Integrations.RewriteFrames({ iteratee: stripUrlBase }),
+ new Integrations.Dedupe()
+ ]
+ });
+
+ sentry.configureScope((scope) => {
+ scope.setUser({ username: userHash });
+ scope.setTag('version', version);
+ scope.setTag('production', isProduction);
+ });
+
+ return createMiddleware();
+}
diff --git a/frontend/src/Store/Middleware/middlewares.js b/frontend/src/Store/Middleware/middlewares.js
new file mode 100644
index 000000000..c4e4ce26f
--- /dev/null
+++ b/frontend/src/Store/Middleware/middlewares.js
@@ -0,0 +1,25 @@
+import { routerMiddleware } from 'connected-react-router';
+import { applyMiddleware, compose } from 'redux';
+import thunk from 'redux-thunk';
+import createPersistState from './createPersistState';
+import createSentryMiddleware from './createSentryMiddleware';
+
+export default function(history) {
+ const middlewares = [];
+ const sentryMiddleware = createSentryMiddleware();
+
+ if (sentryMiddleware) {
+ middlewares.push(sentryMiddleware);
+ }
+
+ middlewares.push(routerMiddleware(history));
+ middlewares.push(thunk);
+
+ // eslint-disable-next-line no-underscore-dangle
+ const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
+
+ return composeEnhancers(
+ applyMiddleware(...middlewares),
+ createPersistState()
+ );
+}
diff --git a/frontend/src/Store/Migrators/migrate.js b/frontend/src/Store/Migrators/migrate.js
new file mode 100644
index 000000000..793b6a209
--- /dev/null
+++ b/frontend/src/Store/Migrators/migrate.js
@@ -0,0 +1,7 @@
+import migrateAddArtistDefaults from './migrateAddArtistDefaults';
+import migrateBlacklistToBlocklist from './migrateBlacklistToBlocklist';
+
+export default function migrate(persistedState) {
+ migrateAddArtistDefaults(persistedState);
+ migrateBlacklistToBlocklist(persistedState);
+}
diff --git a/frontend/src/Store/Migrators/migrateAddArtistDefaults.js b/frontend/src/Store/Migrators/migrateAddArtistDefaults.js
new file mode 100644
index 000000000..731bb00c6
--- /dev/null
+++ b/frontend/src/Store/Migrators/migrateAddArtistDefaults.js
@@ -0,0 +1,14 @@
+import { get } from 'lodash';
+import monitorOptions from 'Utilities/Artist/monitorOptions';
+
+export default function migrateAddArtistDefaults(persistedState) {
+ const monitor = get(persistedState, 'addArtist.defaults.monitor');
+
+ if (!monitor) {
+ return;
+ }
+
+ if (!monitorOptions.find((option) => option.key === monitor)) {
+ persistedState.addArtist.defaults.monitor = monitorOptions[0].key;
+ }
+}
diff --git a/frontend/src/Store/Migrators/migrateBlacklistToBlocklist.js b/frontend/src/Store/Migrators/migrateBlacklistToBlocklist.js
new file mode 100644
index 000000000..3fc7a889e
--- /dev/null
+++ b/frontend/src/Store/Migrators/migrateBlacklistToBlocklist.js
@@ -0,0 +1,12 @@
+import _, { get } from 'lodash';
+
+export default function migrateBlacklistToBlocklist(persistedState) {
+ const blocklist = get(persistedState, 'blacklist');
+
+ if (!blocklist) {
+ return;
+ }
+
+ persistedState.blocklist = blocklist;
+ _.remove(persistedState, 'blacklist');
+}
diff --git a/frontend/src/Store/Selectors/createAlbumSelector.js b/frontend/src/Store/Selectors/createAlbumSelector.js
new file mode 100644
index 000000000..13894a143
--- /dev/null
+++ b/frontend/src/Store/Selectors/createAlbumSelector.js
@@ -0,0 +1,15 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import albumEntities from 'Album/albumEntities';
+
+function createAlbumSelector() {
+ return createSelector(
+ (state, { albumId }) => albumId,
+ (state, { albumEntity = albumEntities.ALBUMS }) => _.get(state, albumEntity, { items: [] }),
+ (albumId, albums) => {
+ return _.find(albums.items, { id: albumId });
+ }
+ );
+}
+
+export default createAlbumSelector;
diff --git a/frontend/src/Store/Selectors/createAllArtistSelector.ts b/frontend/src/Store/Selectors/createAllArtistSelector.ts
new file mode 100644
index 000000000..6b6010429
--- /dev/null
+++ b/frontend/src/Store/Selectors/createAllArtistSelector.ts
@@ -0,0 +1,13 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+
+function createAllArtistSelector() {
+ return createSelector(
+ (state: AppState) => state.artist,
+ (artist) => {
+ return artist.items;
+ }
+ );
+}
+
+export default createAllArtistSelector;
diff --git a/frontend/src/Store/Selectors/createArtistAlbumsSelector.ts b/frontend/src/Store/Selectors/createArtistAlbumsSelector.ts
new file mode 100644
index 000000000..414a451f5
--- /dev/null
+++ b/frontend/src/Store/Selectors/createArtistAlbumsSelector.ts
@@ -0,0 +1,28 @@
+import { createSelector } from 'reselect';
+import AlbumAppState from 'App/State/AlbumAppState';
+import AppState from 'App/State/AppState';
+import Artist from 'Artist/Artist';
+import { createArtistSelectorForHook } from './createArtistSelector';
+
+function createArtistAlbumsSelector(artistId: number) {
+ return createSelector(
+ (state: AppState) => state.albums,
+ createArtistSelectorForHook(artistId),
+ (albums: AlbumAppState, artist = {} as Artist) => {
+ const { isFetching, isPopulated, error, items } = albums;
+
+ const filteredAlbums = items.filter(
+ (album) => album.artistId === artist.id
+ );
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ items: filteredAlbums,
+ };
+ }
+ );
+}
+
+export default createArtistAlbumsSelector;
diff --git a/frontend/src/Store/Selectors/createArtistClientSideCollectionItemsSelector.js b/frontend/src/Store/Selectors/createArtistClientSideCollectionItemsSelector.js
new file mode 100644
index 000000000..3678c92dd
--- /dev/null
+++ b/frontend/src/Store/Selectors/createArtistClientSideCollectionItemsSelector.js
@@ -0,0 +1,45 @@
+import { createSelector, createSelectorCreator, defaultMemoize } from 'reselect';
+import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder';
+import createClientSideCollectionSelector from './createClientSideCollectionSelector';
+
+function createUnoptimizedSelector(uiSection) {
+ return createSelector(
+ createClientSideCollectionSelector('artist', uiSection),
+ (artist) => {
+ const items = artist.items.map((s) => {
+ const {
+ id,
+ sortName
+ } = s;
+
+ return {
+ id,
+ sortName
+ };
+ });
+
+ return {
+ ...artist,
+ items
+ };
+ }
+ );
+}
+
+function artistListEqual(a, b) {
+ return hasDifferentItemsOrOrder(a, b);
+}
+
+const createArtistEqualSelector = createSelectorCreator(
+ defaultMemoize,
+ artistListEqual
+);
+
+function createArtistClientSideCollectionItemsSelector(uiSection) {
+ return createArtistEqualSelector(
+ createUnoptimizedSelector(uiSection),
+ (artist) => artist
+ );
+}
+
+export default createArtistClientSideCollectionItemsSelector;
diff --git a/frontend/src/Store/Selectors/createArtistCountSelector.ts b/frontend/src/Store/Selectors/createArtistCountSelector.ts
new file mode 100644
index 000000000..b432d64a7
--- /dev/null
+++ b/frontend/src/Store/Selectors/createArtistCountSelector.ts
@@ -0,0 +1,22 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import createAllArtistSelector from './createAllArtistSelector';
+
+function createArtistCountSelector() {
+ return createSelector(
+ createAllArtistSelector(),
+ (state: AppState) => state.artist.error,
+ (state: AppState) => state.artist.isFetching,
+ (state: AppState) => state.artist.isPopulated,
+ (artists, error, isFetching, isPopulated) => {
+ return {
+ count: artists.length,
+ error,
+ isFetching,
+ isPopulated,
+ };
+ }
+ );
+}
+
+export default createArtistCountSelector;
diff --git a/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts b/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts
new file mode 100644
index 000000000..fa60d936d
--- /dev/null
+++ b/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts
@@ -0,0 +1,19 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import Artist from 'Artist/Artist';
+import MetadataProfile from 'typings/MetadataProfile';
+import { createArtistSelectorForHook } from './createArtistSelector';
+
+function createArtistMetadataProfileSelector(artistId: number) {
+ return createSelector(
+ (state: AppState) => state.settings.metadataProfiles.items,
+ createArtistSelectorForHook(artistId),
+ (metadataProfiles: MetadataProfile[], artist = {} as Artist) => {
+ return metadataProfiles.find((profile) => {
+ return profile.id === artist.metadataProfileId;
+ });
+ }
+ );
+}
+
+export default createArtistMetadataProfileSelector;
diff --git a/frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts b/frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts
new file mode 100644
index 000000000..67639919b
--- /dev/null
+++ b/frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts
@@ -0,0 +1,19 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import Artist from 'Artist/Artist';
+import QualityProfile from 'typings/QualityProfile';
+import { createArtistSelectorForHook } from './createArtistSelector';
+
+function createArtistQualityProfileSelector(artistId: number) {
+ return createSelector(
+ (state: AppState) => state.settings.qualityProfiles.items,
+ createArtistSelectorForHook(artistId),
+ (qualityProfiles: QualityProfile[], artist = {} as Artist) => {
+ return qualityProfiles.find(
+ (profile) => profile.id === artist.qualityProfileId
+ );
+ }
+ );
+}
+
+export default createArtistQualityProfileSelector;
diff --git a/frontend/src/Store/Selectors/createArtistSelector.js b/frontend/src/Store/Selectors/createArtistSelector.js
new file mode 100644
index 000000000..c335f37f5
--- /dev/null
+++ b/frontend/src/Store/Selectors/createArtistSelector.js
@@ -0,0 +1,24 @@
+import { createSelector } from 'reselect';
+
+export function createArtistSelectorForHook(artistId) {
+ return createSelector(
+ (state) => state.artist.itemMap,
+ (state) => state.artist.items,
+ (itemMap, allArtists) => {
+ return artistId ? allArtists[itemMap[artistId]]: undefined;
+ }
+ );
+}
+
+function createArtistSelector() {
+ return createSelector(
+ (state, { artistId }) => artistId,
+ (state) => state.artist.itemMap,
+ (state) => state.artist.items,
+ (artistId, itemMap, allArtists) => {
+ return allArtists[itemMap[artistId]];
+ }
+ );
+}
+
+export default createArtistSelector;
diff --git a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js
new file mode 100644
index 000000000..1bac14f08
--- /dev/null
+++ b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js
@@ -0,0 +1,144 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import { filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props';
+import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
+
+function getSortClause(sortKey, sortDirection, sortPredicates) {
+ if (sortPredicates && sortPredicates.hasOwnProperty(sortKey)) {
+ return function(item) {
+ return sortPredicates[sortKey](item, sortDirection);
+ };
+ }
+
+ return function(item) {
+ return item[sortKey];
+ };
+}
+
+function filter(items, state) {
+ const {
+ selectedFilterKey,
+ filters,
+ customFilters,
+ filterPredicates
+ } = state;
+
+ if (!selectedFilterKey) {
+ return items;
+ }
+
+ const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters);
+
+ return _.filter(items, (item) => {
+ let i = 0;
+ let accepted = true;
+
+ while (accepted && i < selectedFilters.length) {
+ const {
+ key,
+ value,
+ type = filterTypes.EQUAL
+ } = selectedFilters[i];
+
+ if (filterPredicates && filterPredicates.hasOwnProperty(key)) {
+ const predicate = filterPredicates[key];
+
+ if (Array.isArray(value)) {
+ if (
+ type === filterTypes.NOT_CONTAINS ||
+ type === filterTypes.NOT_EQUAL
+ ) {
+ accepted = value.every((v) => predicate(item, v, type));
+ } else {
+ accepted = value.some((v) => predicate(item, v, type));
+ }
+ } else {
+ accepted = predicate(item, value, type);
+ }
+ } else if (item.hasOwnProperty(key)) {
+ const predicate = filterTypePredicates[type];
+
+ if (Array.isArray(value)) {
+ if (
+ type === filterTypes.NOT_CONTAINS ||
+ type === filterTypes.NOT_EQUAL
+ ) {
+ accepted = value.every((v) => predicate(item[key], v));
+ } else {
+ accepted = value.some((v) => predicate(item[key], v));
+ }
+ } else {
+ accepted = predicate(item[key], value);
+ }
+ } else {
+ // Default to false if the filter can't be tested
+ accepted = false;
+ }
+
+ i++;
+ }
+
+ return accepted;
+ });
+}
+
+function sort(items, state) {
+ const {
+ sortKey,
+ sortDirection,
+ sortPredicates,
+ secondarySortKey,
+ secondarySortDirection
+ } = state;
+
+ const clauses = [];
+ const orders = [];
+
+ clauses.push(getSortClause(sortKey, sortDirection, sortPredicates));
+ orders.push(sortDirection === sortDirections.ASCENDING ? 'asc' : 'desc');
+
+ if (secondarySortKey &&
+ secondarySortDirection &&
+ (sortKey !== secondarySortKey ||
+ sortDirection !== secondarySortDirection)) {
+ clauses.push(getSortClause(secondarySortKey, secondarySortDirection, sortPredicates));
+ orders.push(secondarySortDirection === sortDirections.ASCENDING ? 'asc' : 'desc');
+ }
+
+ return _.orderBy(items, clauses, orders);
+}
+
+export function createCustomFiltersSelector(type, alternateType) {
+ return createSelector(
+ (state) => state.customFilters.items,
+ (customFilters) => {
+ return customFilters.filter((customFilter) => {
+ return customFilter.type === type || customFilter.type === alternateType;
+ });
+ }
+ );
+}
+
+function createClientSideCollectionSelector(section, uiSection) {
+ return createSelector(
+ (state) => _.get(state, section),
+ (state) => _.get(state, uiSection),
+ createCustomFiltersSelector(section, uiSection),
+ (sectionState, uiSectionState = {}, customFilters) => {
+ const state = Object.assign({}, sectionState, uiSectionState, { customFilters });
+
+ const filtered = filter(state.items, state);
+ const sorted = sort(filtered, state);
+
+ return {
+ ...sectionState,
+ ...uiSectionState,
+ customFilters,
+ items: sorted,
+ totalItems: state.items.length
+ };
+ }
+ );
+}
+
+export default createClientSideCollectionSelector;
diff --git a/frontend/src/Store/Selectors/createCommandExecutingSelector.ts b/frontend/src/Store/Selectors/createCommandExecutingSelector.ts
new file mode 100644
index 000000000..6a80e172b
--- /dev/null
+++ b/frontend/src/Store/Selectors/createCommandExecutingSelector.ts
@@ -0,0 +1,11 @@
+import { createSelector } from 'reselect';
+import { isCommandExecuting } from 'Utilities/Command';
+import createCommandSelector from './createCommandSelector';
+
+function createCommandExecutingSelector(name: string, contraints = {}) {
+ return createSelector(createCommandSelector(name, contraints), (command) => {
+ return isCommandExecuting(command);
+ });
+}
+
+export default createCommandExecutingSelector;
diff --git a/frontend/src/Store/Selectors/createCommandSelector.ts b/frontend/src/Store/Selectors/createCommandSelector.ts
new file mode 100644
index 000000000..cced7b186
--- /dev/null
+++ b/frontend/src/Store/Selectors/createCommandSelector.ts
@@ -0,0 +1,11 @@
+import { createSelector } from 'reselect';
+import { findCommand } from 'Utilities/Command';
+import createCommandsSelector from './createCommandsSelector';
+
+function createCommandSelector(name: string, contraints = {}) {
+ return createSelector(createCommandsSelector(), (commands) => {
+ return findCommand(commands, { name, ...contraints });
+ });
+}
+
+export default createCommandSelector;
diff --git a/frontend/src/Store/Selectors/createCommandsSelector.ts b/frontend/src/Store/Selectors/createCommandsSelector.ts
new file mode 100644
index 000000000..2dd5d24a2
--- /dev/null
+++ b/frontend/src/Store/Selectors/createCommandsSelector.ts
@@ -0,0 +1,13 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+
+function createCommandsSelector() {
+ return createSelector(
+ (state: AppState) => state.commands,
+ (commands) => {
+ return commands.items;
+ }
+ );
+}
+
+export default createCommandsSelector;
diff --git a/frontend/src/Store/Selectors/createDeepEqualSelector.ts b/frontend/src/Store/Selectors/createDeepEqualSelector.ts
new file mode 100644
index 000000000..9d4a63d2e
--- /dev/null
+++ b/frontend/src/Store/Selectors/createDeepEqualSelector.ts
@@ -0,0 +1,6 @@
+import { isEqual } from 'lodash';
+import { createSelectorCreator, defaultMemoize } from 'reselect';
+
+const createDeepEqualSelector = createSelectorCreator(defaultMemoize, isEqual);
+
+export default createDeepEqualSelector;
diff --git a/frontend/src/Store/Selectors/createDimensionsSelector.ts b/frontend/src/Store/Selectors/createDimensionsSelector.ts
new file mode 100644
index 000000000..b9602cb02
--- /dev/null
+++ b/frontend/src/Store/Selectors/createDimensionsSelector.ts
@@ -0,0 +1,13 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+
+function createDimensionsSelector() {
+ return createSelector(
+ (state: AppState) => state.app.dimensions,
+ (dimensions) => {
+ return dimensions;
+ }
+ );
+}
+
+export default createDimensionsSelector;
diff --git a/frontend/src/Store/Selectors/createExecutingCommandsSelector.ts b/frontend/src/Store/Selectors/createExecutingCommandsSelector.ts
new file mode 100644
index 000000000..dd16571fc
--- /dev/null
+++ b/frontend/src/Store/Selectors/createExecutingCommandsSelector.ts
@@ -0,0 +1,14 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import { isCommandExecuting } from 'Utilities/Command';
+
+function createExecutingCommandsSelector() {
+ return createSelector(
+ (state: AppState) => state.commands.items,
+ (commands) => {
+ return commands.filter((command) => isCommandExecuting(command));
+ }
+ );
+}
+
+export default createExecutingCommandsSelector;
diff --git a/frontend/src/Store/Selectors/createExistingArtistSelector.ts b/frontend/src/Store/Selectors/createExistingArtistSelector.ts
new file mode 100644
index 000000000..91b5bc4d6
--- /dev/null
+++ b/frontend/src/Store/Selectors/createExistingArtistSelector.ts
@@ -0,0 +1,17 @@
+import { some } from 'lodash';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import createAllArtistSelector from './createAllArtistSelector';
+
+function createExistingArtistSelector() {
+ return createSelector(
+ (_: AppState, { foreignArtistId }: { foreignArtistId: string }) =>
+ foreignArtistId,
+ createAllArtistSelector(),
+ (foreignArtistId, artist) => {
+ return some(artist, { foreignArtistId });
+ }
+ );
+}
+
+export default createExistingArtistSelector;
diff --git a/frontend/src/Store/Selectors/createHealthCheckSelector.js b/frontend/src/Store/Selectors/createHealthCheckSelector.js
new file mode 100644
index 000000000..86fca05b3
--- /dev/null
+++ b/frontend/src/Store/Selectors/createHealthCheckSelector.js
@@ -0,0 +1,24 @@
+import { createSelector } from 'reselect';
+
+function createHealthCheckSelector() {
+ return createSelector(
+ (state) => state.system.health,
+ (state) => state.app,
+ (health, app) => {
+ const items = [...health.items];
+
+ if (!app.isConnected) {
+ items.push({
+ source: 'UI',
+ type: 'warning',
+ message: 'Could not connect to SignalR, UI won\'t update',
+ wikiUrl: 'https://wiki.servarr.com/lidarr/system#could-not-connect-to-signalr'
+ });
+ }
+
+ return items;
+ }
+ );
+}
+
+export default createHealthCheckSelector;
diff --git a/frontend/src/Store/Selectors/createImportArtistItemSelector.js b/frontend/src/Store/Selectors/createImportArtistItemSelector.js
new file mode 100644
index 000000000..22406c853
--- /dev/null
+++ b/frontend/src/Store/Selectors/createImportArtistItemSelector.js
@@ -0,0 +1,26 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import createAllArtistSelector from './createAllArtistSelector';
+
+function createImportArtistItemSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.addArtist,
+ (state) => state.importArtist,
+ createAllArtistSelector(),
+ (id, addArtist, importArtist, artist) => {
+ const item = _.find(importArtist.items, { id }) || {};
+ const selectedArtist = item && item.selectedArtist;
+ const isExistingArtist = !!selectedArtist && _.some(artist, { foreignArtistId: selectedArtist.foreignArtistId });
+
+ return {
+ defaultMonitor: addArtist.defaults.monitor,
+ defaultQualityProfileId: addArtist.defaults.qualityProfileId,
+ ...item,
+ isExistingArtist
+ };
+ }
+ );
+}
+
+export default createImportArtistItemSelector;
diff --git a/frontend/src/Store/Selectors/createIndexerFlagsSelector.ts b/frontend/src/Store/Selectors/createIndexerFlagsSelector.ts
new file mode 100644
index 000000000..90587639c
--- /dev/null
+++ b/frontend/src/Store/Selectors/createIndexerFlagsSelector.ts
@@ -0,0 +1,9 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+
+const createIndexerFlagsSelector = createSelector(
+ (state: AppState) => state.settings.indexerFlags,
+ (indexerFlags) => indexerFlags
+);
+
+export default createIndexerFlagsSelector;
diff --git a/frontend/src/Store/Selectors/createMetadataProfileSelector.ts b/frontend/src/Store/Selectors/createMetadataProfileSelector.ts
new file mode 100644
index 000000000..ae4c061db
--- /dev/null
+++ b/frontend/src/Store/Selectors/createMetadataProfileSelector.ts
@@ -0,0 +1,17 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+
+function createMetadataProfileSelector() {
+ return createSelector(
+ (_: AppState, { metadataProfileId }: { metadataProfileId: number }) =>
+ metadataProfileId,
+ (state: AppState) => state.settings.metadataProfiles.items,
+ (metadataProfileId, metadataProfiles) => {
+ return metadataProfiles.find(
+ (profile) => profile.id === metadataProfileId
+ );
+ }
+ );
+}
+
+export default createMetadataProfileSelector;
diff --git a/frontend/src/Store/Selectors/createMultiArtistsSelector.ts b/frontend/src/Store/Selectors/createMultiArtistsSelector.ts
new file mode 100644
index 000000000..d8f7ea92b
--- /dev/null
+++ b/frontend/src/Store/Selectors/createMultiArtistsSelector.ts
@@ -0,0 +1,23 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import Artist from 'Artist/Artist';
+
+function createMultiArtistsSelector(artistIds: number[]) {
+ return createSelector(
+ (state: AppState) => state.artist.itemMap,
+ (state: AppState) => state.artist.items,
+ (itemMap, allArtists) => {
+ return artistIds.reduce((acc: Artist[], artistId) => {
+ const artist = allArtists[itemMap[artistId]];
+
+ if (artist) {
+ acc.push(artist);
+ }
+
+ return acc;
+ }, []);
+ }
+ );
+}
+
+export default createMultiArtistsSelector;
diff --git a/frontend/src/Store/Selectors/createProfileInUseSelector.ts b/frontend/src/Store/Selectors/createProfileInUseSelector.ts
new file mode 100644
index 000000000..85f0c3211
--- /dev/null
+++ b/frontend/src/Store/Selectors/createProfileInUseSelector.ts
@@ -0,0 +1,25 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import Artist from 'Artist/Artist';
+import ImportList from 'typings/ImportList';
+import createAllArtistSelector from './createAllArtistSelector';
+
+function createProfileInUseSelector(profileProp: string) {
+ return createSelector(
+ (_: AppState, { id }: { id: number }) => id,
+ createAllArtistSelector(),
+ (state: AppState) => state.settings.importLists.items,
+ (id, artists, lists) => {
+ if (!id) {
+ return false;
+ }
+
+ return (
+ artists.some((a) => a[profileProp as keyof Artist] === id) ||
+ lists.some((list) => list[profileProp as keyof ImportList] === id)
+ );
+ }
+ );
+}
+
+export default createProfileInUseSelector;
diff --git a/frontend/src/Store/Selectors/createProviderSettingsSelector.js b/frontend/src/Store/Selectors/createProviderSettingsSelector.js
new file mode 100644
index 000000000..f5ac9bad5
--- /dev/null
+++ b/frontend/src/Store/Selectors/createProviderSettingsSelector.js
@@ -0,0 +1,71 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+
+function selector(id, section) {
+ if (!id) {
+ const item = _.isArray(section.schema) ? section.selectedSchema : section.schema;
+ const settings = selectSettings(Object.assign({ name: '' }, item), section.pendingChanges, section.saveError);
+
+ const {
+ isSchemaFetching: isFetching,
+ isSchemaPopulated: isPopulated,
+ schemaError: error,
+ isSaving,
+ saveError,
+ isTesting,
+ pendingChanges
+ } = section;
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ isSaving,
+ saveError,
+ isTesting,
+ pendingChanges,
+ ...settings,
+ item: settings.settings
+ };
+ }
+
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ isSaving,
+ saveError,
+ isTesting,
+ pendingChanges
+ } = section;
+
+ const settings = selectSettings(_.find(section.items, { id }), pendingChanges, saveError);
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ isSaving,
+ saveError,
+ isTesting,
+ ...settings,
+ item: settings.settings
+ };
+}
+
+export default function createProviderSettingsSelector(sectionName) {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings[sectionName],
+ (id, section) => selector(id, section)
+ );
+}
+
+export function createProviderSettingsSelectorHook(sectionName, id) {
+ return createSelector(
+ (state) => state.settings[sectionName],
+ (section) => selector(id, section)
+ );
+}
+
diff --git a/frontend/src/Store/Selectors/createQualityProfileSelector.ts b/frontend/src/Store/Selectors/createQualityProfileSelector.ts
new file mode 100644
index 000000000..b913e0c46
--- /dev/null
+++ b/frontend/src/Store/Selectors/createQualityProfileSelector.ts
@@ -0,0 +1,24 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+
+export function createQualityProfileSelectorForHook(qualityProfileId: number) {
+ return createSelector(
+ (state: AppState) => state.settings.qualityProfiles.items,
+ (qualityProfiles) => {
+ return qualityProfiles.find((profile) => profile.id === qualityProfileId);
+ }
+ );
+}
+
+function createQualityProfileSelector() {
+ return createSelector(
+ (_: AppState, { qualityProfileId }: { qualityProfileId: number }) =>
+ qualityProfileId,
+ (state: AppState) => state.settings.qualityProfiles.items,
+ (qualityProfileId, qualityProfiles) => {
+ return qualityProfiles.find((profile) => profile.id === qualityProfileId);
+ }
+ );
+}
+
+export default createQualityProfileSelector;
diff --git a/frontend/src/Store/Selectors/createQueueItemSelector.ts b/frontend/src/Store/Selectors/createQueueItemSelector.ts
new file mode 100644
index 000000000..54951a724
--- /dev/null
+++ b/frontend/src/Store/Selectors/createQueueItemSelector.ts
@@ -0,0 +1,18 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+
+function createQueueItemSelector() {
+ return createSelector(
+ (_: AppState, { albumId }: { albumId: number }) => albumId,
+ (state: AppState) => state.queue.details.items,
+ (albumId, details) => {
+ if (!albumId || !details) {
+ return null;
+ }
+
+ return details.find((item) => item.albumId === albumId);
+ }
+ );
+}
+
+export default createQueueItemSelector;
diff --git a/frontend/src/Store/Selectors/createRootFoldersSelector.ts b/frontend/src/Store/Selectors/createRootFoldersSelector.ts
new file mode 100644
index 000000000..432f9056d
--- /dev/null
+++ b/frontend/src/Store/Selectors/createRootFoldersSelector.ts
@@ -0,0 +1,15 @@
+import { createSelector } from 'reselect';
+import { RootFolderAppState } from 'App/State/SettingsAppState';
+import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
+import RootFolder from 'typings/RootFolder';
+import sortByProp from 'Utilities/Array/sortByProp';
+
+export default function createRootFoldersSelector() {
+ return createSelector(
+ createSortedSectionSelector(
+ 'settings.rootFolders',
+ sortByProp('name')
+ ),
+ (rootFolders: RootFolderAppState) => rootFolders
+ );
+}
diff --git a/frontend/src/Store/Selectors/createSettingsSectionSelector.js b/frontend/src/Store/Selectors/createSettingsSectionSelector.js
new file mode 100644
index 000000000..a9f6cbff6
--- /dev/null
+++ b/frontend/src/Store/Selectors/createSettingsSectionSelector.js
@@ -0,0 +1,32 @@
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+
+function createSettingsSectionSelector(section) {
+ return createSelector(
+ (state) => state.settings[section],
+ (sectionSettings) => {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ item,
+ pendingChanges,
+ isSaving,
+ saveError
+ } = sectionSettings;
+
+ const settings = selectSettings(item, pendingChanges, saveError);
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ isSaving,
+ saveError,
+ ...settings
+ };
+ }
+ );
+}
+
+export default createSettingsSectionSelector;
diff --git a/frontend/src/Store/Selectors/createSortedSectionSelector.ts b/frontend/src/Store/Selectors/createSortedSectionSelector.ts
new file mode 100644
index 000000000..abee01f75
--- /dev/null
+++ b/frontend/src/Store/Selectors/createSortedSectionSelector.ts
@@ -0,0 +1,21 @@
+import { createSelector } from 'reselect';
+import getSectionState from 'Utilities/State/getSectionState';
+
+function createSortedSectionSelector(
+ section: string,
+ comparer: (a: T, b: T) => number
+) {
+ return createSelector(
+ (state) => state,
+ (state) => {
+ const sectionState = getSectionState(state, section, true);
+
+ return {
+ ...sectionState,
+ items: [...sectionState.items].sort(comparer),
+ };
+ }
+ );
+}
+
+export default createSortedSectionSelector;
diff --git a/frontend/src/Store/Selectors/createSystemStatusSelector.ts b/frontend/src/Store/Selectors/createSystemStatusSelector.ts
new file mode 100644
index 000000000..f5e276069
--- /dev/null
+++ b/frontend/src/Store/Selectors/createSystemStatusSelector.ts
@@ -0,0 +1,13 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+
+function createSystemStatusSelector() {
+ return createSelector(
+ (state: AppState) => state.system.status,
+ (status) => {
+ return status.item;
+ }
+ );
+}
+
+export default createSystemStatusSelector;
diff --git a/frontend/src/Store/Selectors/createTagDetailsSelector.ts b/frontend/src/Store/Selectors/createTagDetailsSelector.ts
new file mode 100644
index 000000000..2a271cafe
--- /dev/null
+++ b/frontend/src/Store/Selectors/createTagDetailsSelector.ts
@@ -0,0 +1,14 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+
+function createTagDetailsSelector() {
+ return createSelector(
+ (_: AppState, { id }: { id: number }) => id,
+ (state: AppState) => state.tags.details.items,
+ (id, tagDetails) => {
+ return tagDetails.find((t) => t.id === id);
+ }
+ );
+}
+
+export default createTagDetailsSelector;
diff --git a/frontend/src/Store/Selectors/createTagsSelector.ts b/frontend/src/Store/Selectors/createTagsSelector.ts
new file mode 100644
index 000000000..f653ff6e3
--- /dev/null
+++ b/frontend/src/Store/Selectors/createTagsSelector.ts
@@ -0,0 +1,13 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+
+function createTagsSelector() {
+ return createSelector(
+ (state: AppState) => state.tags.items,
+ (tags) => {
+ return tags;
+ }
+ );
+}
+
+export default createTagsSelector;
diff --git a/frontend/src/Store/Selectors/createTrackFileSelector.ts b/frontend/src/Store/Selectors/createTrackFileSelector.ts
new file mode 100644
index 000000000..a162df1fa
--- /dev/null
+++ b/frontend/src/Store/Selectors/createTrackFileSelector.ts
@@ -0,0 +1,18 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+
+function createTrackFileSelector() {
+ return createSelector(
+ (_: AppState, { trackFileId }: { trackFileId: number }) => trackFileId,
+ (state: AppState) => state.trackFiles,
+ (trackFileId, trackFiles) => {
+ if (!trackFileId) {
+ return;
+ }
+
+ return trackFiles.items.find((trackFile) => trackFile.id === trackFileId);
+ }
+ );
+}
+
+export default createTrackFileSelector;
diff --git a/frontend/src/Store/Selectors/createTrackSelector.js b/frontend/src/Store/Selectors/createTrackSelector.js
new file mode 100644
index 000000000..be57e6ca0
--- /dev/null
+++ b/frontend/src/Store/Selectors/createTrackSelector.js
@@ -0,0 +1,14 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+
+function createTrackSelector() {
+ return createSelector(
+ (state, { trackId }) => trackId,
+ (state) => state.tracks,
+ (trackId, tracks) => {
+ return _.find(tracks.items, { id: trackId });
+ }
+ );
+}
+
+export default createTrackSelector;
diff --git a/frontend/src/Store/Selectors/createUISettingsSelector.ts b/frontend/src/Store/Selectors/createUISettingsSelector.ts
new file mode 100644
index 000000000..ff539679b
--- /dev/null
+++ b/frontend/src/Store/Selectors/createUISettingsSelector.ts
@@ -0,0 +1,13 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+
+function createUISettingsSelector() {
+ return createSelector(
+ (state: AppState) => state.settings.ui,
+ (ui) => {
+ return ui.item;
+ }
+ );
+}
+
+export default createUISettingsSelector;
diff --git a/frontend/src/Store/Selectors/selectSettings.js b/frontend/src/Store/Selectors/selectSettings.js
new file mode 100644
index 000000000..3e30478b7
--- /dev/null
+++ b/frontend/src/Store/Selectors/selectSettings.js
@@ -0,0 +1,104 @@
+import _ from 'lodash';
+
+function getValidationFailures(saveError) {
+ if (!saveError || saveError.status !== 400) {
+ return [];
+ }
+
+ return _.cloneDeep(saveError.responseJSON);
+}
+
+function mapFailure(failure) {
+ return {
+ message: failure.errorMessage,
+ link: failure.infoLink,
+ detailedMessage: failure.detailedDescription
+ };
+}
+
+function selectSettings(item, pendingChanges, saveError) {
+ const validationFailures = getValidationFailures(saveError);
+
+ // Merge all settings from the item along with pending
+ // changes to ensure any settings that were not included
+ // with the item are included.
+ const allSettings = Object.assign({}, item, pendingChanges);
+
+ const settings = _.reduce(allSettings, (result, value, key) => {
+ if (key === 'fields') {
+ return result;
+ }
+
+ // Return a flattened value
+ if (key === 'implementationName') {
+ result.implementationName = item[key];
+
+ return result;
+ }
+
+ const setting = {
+ value: item[key],
+ errors: _.map(_.remove(validationFailures, (failure) => {
+ return failure.propertyName.toLowerCase() === key.toLowerCase() && !failure.isWarning;
+ }), mapFailure),
+
+ warnings: _.map(_.remove(validationFailures, (failure) => {
+ return failure.propertyName.toLowerCase() === key.toLowerCase() && failure.isWarning;
+ }), mapFailure)
+ };
+
+ if (pendingChanges.hasOwnProperty(key)) {
+ setting.previousValue = setting.value;
+ setting.value = pendingChanges[key];
+ setting.pending = true;
+ }
+
+ result[key] = setting;
+ return result;
+ }, {});
+
+ const fields = _.reduce(item.fields, (result, f) => {
+ const field = Object.assign({ pending: false }, f);
+ const hasPendingFieldChange = pendingChanges.fields && pendingChanges.fields.hasOwnProperty(field.name);
+
+ if (hasPendingFieldChange) {
+ field.previousValue = field.value;
+ field.value = pendingChanges.fields[field.name];
+ field.pending = true;
+ }
+
+ field.errors = _.map(_.remove(validationFailures, (failure) => {
+ return failure.propertyName.toLowerCase() === field.name.toLowerCase() && !failure.isWarning;
+ }), mapFailure);
+
+ field.warnings = _.map(_.remove(validationFailures, (failure) => {
+ return failure.propertyName.toLowerCase() === field.name.toLowerCase() && failure.isWarning;
+ }), mapFailure);
+
+ result.push(field);
+ return result;
+ }, []);
+
+ if (fields.length) {
+ settings.fields = fields;
+ }
+
+ const validationErrors = _.filter(validationFailures, (failure) => {
+ return !failure.isWarning;
+ });
+
+ const validationWarnings = _.filter(validationFailures, (failure) => {
+ return failure.isWarning;
+ });
+
+ return {
+ settings,
+ validationErrors,
+ validationWarnings,
+ hasPendingChanges: !_.isEmpty(pendingChanges),
+ hasSettings: !_.isEmpty(settings),
+ pendingChanges
+ };
+}
+
+export default selectSettings;
diff --git a/frontend/src/Store/createAppStore.js b/frontend/src/Store/createAppStore.js
new file mode 100644
index 000000000..4fef265f1
--- /dev/null
+++ b/frontend/src/Store/createAppStore.js
@@ -0,0 +1,15 @@
+import { createStore } from 'redux';
+import createReducers, { defaultState } from 'Store/Actions/createReducers';
+import middlewares from 'Store/Middleware/middlewares';
+
+function createAppStore(history) {
+ const appStore = createStore(
+ createReducers(history),
+ defaultState,
+ middlewares(history)
+ );
+
+ return appStore;
+}
+
+export default createAppStore;
diff --git a/frontend/src/Store/scrollPositions.ts b/frontend/src/Store/scrollPositions.ts
new file mode 100644
index 000000000..199bfa84c
--- /dev/null
+++ b/frontend/src/Store/scrollPositions.ts
@@ -0,0 +1,5 @@
+const scrollPositions: Record = {
+ artistIndex: 0,
+};
+
+export default scrollPositions;
diff --git a/frontend/src/Store/thunks.ts b/frontend/src/Store/thunks.ts
new file mode 100644
index 000000000..fd277211e
--- /dev/null
+++ b/frontend/src/Store/thunks.ts
@@ -0,0 +1,39 @@
+import { Dispatch } from 'redux';
+import AppState from 'App/State/AppState';
+
+type GetState = () => AppState;
+type Thunk = (
+ getState: GetState,
+ identityFn: never,
+ dispatch: Dispatch
+) => unknown;
+
+const thunks: Record = {};
+
+function identity(payload: T): TResult {
+ return payload as unknown as TResult;
+}
+
+export function createThunk(type: string, identityFunction = identity) {
+ return function (payload?: T) {
+ return function (dispatch: Dispatch, getState: GetState) {
+ const thunk = thunks[type];
+
+ if (thunk) {
+ const finalPayload = payload ?? {};
+
+ return thunk(getState, identityFunction(finalPayload), dispatch);
+ }
+
+ throw Error(`Thunk handler has not been registered for ${type}`);
+ };
+ };
+}
+
+export function handleThunks(handlers: Record) {
+ const types = Object.keys(handlers);
+
+ types.forEach((type) => {
+ thunks[type] = handlers[type];
+ });
+}
diff --git a/src/UI/Shared/Styles/clickable.less b/frontend/src/Styles/Mixins/clickable.css
similarity index 100%
rename from src/UI/Shared/Styles/clickable.less
rename to frontend/src/Styles/Mixins/clickable.css
diff --git a/frontend/src/Styles/Mixins/cover.css b/frontend/src/Styles/Mixins/cover.css
new file mode 100644
index 000000000..e44c99be6
--- /dev/null
+++ b/frontend/src/Styles/Mixins/cover.css
@@ -0,0 +1,8 @@
+@define-mixin cover {
+ position: absolute;
+ top: 0;
+ left: 0;
+ display: block;
+ width: 100%;
+ height: 100%;
+}
diff --git a/frontend/src/Styles/Mixins/linkOverlay.css b/frontend/src/Styles/Mixins/linkOverlay.css
new file mode 100644
index 000000000..74c3fd753
--- /dev/null
+++ b/frontend/src/Styles/Mixins/linkOverlay.css
@@ -0,0 +1,11 @@
+@define-mixin linkOverlay {
+ @add-mixin cover;
+
+ pointer-events: none;
+ user-select: none;
+
+ a,
+ button {
+ pointer-events: all;
+ }
+}
diff --git a/frontend/src/Styles/Mixins/scroller.css b/frontend/src/Styles/Mixins/scroller.css
new file mode 100644
index 000000000..29b2016b9
--- /dev/null
+++ b/frontend/src/Styles/Mixins/scroller.css
@@ -0,0 +1,29 @@
+@define-mixin scrollbar {
+ scrollbar-color: var(--scrollbarBackgroundColor) transparent;
+ scrollbar-width: thin;
+
+ &::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+ }
+}
+
+@define-mixin scrollbarTrack {
+ &::-webkit-scrollbar-track {
+ background-color: transparent;
+ }
+}
+
+@define-mixin scrollbarThumb {
+ &::-webkit-scrollbar-thumb {
+ min-height: 100px;
+ border: 1px solid transparent;
+ border-radius: 5px;
+ background-color: var(--scrollbarBackgroundColor);
+ background-clip: padding-box;
+
+ &:hover {
+ background-color: var(--scrollbarHoverBackgroundColor);
+ }
+ }
+}
diff --git a/frontend/src/Styles/Mixins/truncate.css b/frontend/src/Styles/Mixins/truncate.css
new file mode 100644
index 000000000..1941afc9b
--- /dev/null
+++ b/frontend/src/Styles/Mixins/truncate.css
@@ -0,0 +1,18 @@
+/**
+ * From: https://github.com/suitcss/utils-text/blob/master/lib/text.css
+ *
+ * Text truncation
+ *
+ * Prevent text from wrapping onto multiple lines, and truncate with an
+ * ellipsis.
+ *
+ * 1. Ensure that the node has a maximum width after which truncation can
+ * occur.
+ */
+
+@define-mixin truncate {
+ overflow: hidden !important;
+ max-width: 100%; /* 1 */
+ text-overflow: ellipsis !important;
+ white-space: nowrap !important;
+}
diff --git a/frontend/src/Styles/Themes/dark.js b/frontend/src/Styles/Themes/dark.js
new file mode 100644
index 000000000..7513139a4
--- /dev/null
+++ b/frontend/src/Styles/Themes/dark.js
@@ -0,0 +1,241 @@
+const lidarrGreen = '#00A65B';
+const darkGray = '#888';
+const gray = '#adadad';
+const black = '#000';
+const white = '#fff';
+const offWhite = '#f5f7fa';
+const purple = '#7a43b6';
+const pink = '#ff69b4';
+const lightGray = '#ddd';
+
+module.exports = {
+ textColor: '#ccc',
+ defaultColor: '#ccc',
+ disabledColor: '#999',
+ dimColor: '#555',
+ black,
+ white,
+ offWhite,
+ blue: '#06f',
+ primaryColor: '#5d9cec',
+ selectedColor: '#f9be03',
+ successColor: '#00853d',
+ dangerColor: '#f05050',
+ warningColor: '#ffa500',
+ infoColor: lidarrGreen,
+ purple,
+ pink,
+ lidarrGreen,
+ helpTextColor: '#909293',
+ darkGray,
+ gray,
+ lightGray,
+
+ // Theme Colors
+
+ themeBlue: lidarrGreen,
+ themeAlternateBlue: '#00a65b',
+ themeRed: '#c4273c',
+ themeDarkColor: '#494949',
+ themeLightColor: '#595959',
+ pageBackground: '#202020',
+ pageFooterBackground: 'rgba(0, 0, 0, .25)',
+
+ torrentColor: '#00853d',
+ usenetColor: '#17b1d9',
+
+ // Labels
+ inverseLabelColor: '#ddd',
+ inverseLabelTextColor: '#333',
+ disabledLabelColor: '#838383',
+ infoTextColor: white,
+
+ // Links
+ defaultLinkHoverColor: '#fff',
+ linkColor: '#fff',
+ linkHoverColor: '#0b8750',
+
+ // Header
+ pageHeaderBackgroundColor: '#2a2a2a',
+
+ // Sidebar
+
+ sidebarColor: '#e1e2e3',
+ sidebarBackgroundColor: '#2a2a2a',
+ sidebarActiveBackgroundColor: '#333333',
+
+ // Toolbar
+ toolbarColor: '#e1e2e3',
+ toolbarBackgroundColor: '#262626',
+ toolbarMenuItemBackgroundColor: '#333',
+ toolbarMenuItemHoverBackgroundColor: '#414141',
+ toolbarLabelColor: '#e1e2e3',
+
+ // Accents
+ borderColor: '#858585',
+ inputBorderColor: '#dde6e9',
+ inputBoxShadowColor: 'rgba(0, 0, 0, 0.075)',
+ inputFocusBorderColor: '#66afe9',
+ inputFocusBoxShadowColor: 'rgba(102, 175, 233, 0.6)',
+ inputErrorBorderColor: '#f05050',
+ inputErrorBoxShadowColor: 'rgba(240, 80, 80, 0.6)',
+ inputWarningBorderColor: '#ffa500',
+ inputWarningBoxShadowColor: 'rgba(255, 165, 0, 0.6)',
+ colorImpairedGradient: '#707070',
+ colorImpairedGradientDark: '#424242',
+ colorImpairedDangerGradient: '#d84848',
+ colorImpairedWarningGradient: '#e59400',
+ colorImpairedPrimaryGradient: '#538cd4',
+ colorImpairedGrayGradient: '#9b9b9b',
+
+ //
+ // Buttons
+
+ defaultButtonTextColor: '#eee',
+ defaultBackgroundColor: '#333',
+ defaultBorderColor: '#393f45',
+ defaultHoverBackgroundColor: '#444',
+ defaultHoverBorderColor: '#5a6265',
+
+ primaryBackgroundColor: '#0b8750',
+ primaryBorderColor: '#1d563d',
+ primaryHoverBackgroundColor: '#097948',
+ primaryHoverBorderColor: '#1D563D',
+
+ successBackgroundColor: '#27c24c',
+ successBorderColor: '#26be4a',
+ successHoverBackgroundColor: '#24b145',
+ successHoverBorderColor: '#1f9c3d',
+
+ warningBackgroundColor: '#ff902b',
+ warningBorderColor: '#ff8d26',
+ warningHoverBackgroundColor: '#ff8517',
+ warningHoverBorderColor: '#fc7800',
+
+ dangerBackgroundColor: '#f05050',
+ dangerBorderColor: '#f04b4b',
+ dangerHoverBackgroundColor: '#ee3d3d',
+ dangerHoverBorderColor: '#ec2626',
+
+ iconButtonDisabledColor: '#7a7a7a',
+ iconButtonHoverColor: '#666',
+ iconButtonHoverLightColor: '#ccc',
+
+ //
+ // Modal
+
+ modalBackdropBackgroundColor: 'rgba(0, 0, 0, 0.6)',
+ modalBackgroundColor: '#2a2a2a',
+ modalCloseButtonHoverColor: '#888',
+
+ //
+ // Menu
+ menuItemColor: '#e1e2e3',
+ menuItemHoverColor: lidarrGreen,
+ menuItemHoverBackgroundColor: '#606060',
+
+ //
+ // Toolbar
+
+ toobarButtonHoverColor: lidarrGreen,
+ toobarButtonSelectedColor: lidarrGreen,
+
+ //
+ // Scroller
+
+ scrollbarBackgroundColor: '#707070',
+ scrollbarHoverBackgroundColor: '#606060',
+
+ //
+ // Card
+
+ cardBackgroundColor: '#333333',
+ cardShadowColor: '#111',
+ cardAlternateBackgroundColor: '#333333',
+ cardCenterBackgroundColor: '#2a2a2a',
+
+ //
+ // Alert
+
+ alertDangerBorderColor: '#a94442',
+ alertDangerBackgroundColor: 'rgba(255,0,0,0.1)',
+ alertDangerColor: '#ccc',
+
+ alertInfoBorderColor: '#31708f',
+ alertInfoBackgroundColor: 'rgba(0,0,255,0.1)',
+ alertInfoColor: '#ccc',
+
+ alertSuccessBorderColor: '#3c763d',
+ alertSuccessBackgroundColor: 'rgba(0,255,0,0.1)',
+ alertSuccessColor: '#ccc',
+
+ alertWarningBorderColor: '#8a6d3b',
+ alertWarningBackgroundColor: 'rgba(255,255,0,0.1)',
+ alertWarningColor: '#ccc',
+
+ //
+ // Slider
+
+ sliderAccentColor: '#0b8750',
+
+ //
+ // Form
+
+ inputBackgroundColor: '#333',
+ inputReadOnlyBackgroundColor: '#222',
+ inputHoverBackgroundColor: 'rgba(255, 255, 255, 0.20)',
+ inputSelectedBackgroundColor: 'rgba(255, 255, 255, 0.05)',
+ advancedFormLabelColor: '#ff902b',
+ disabledCheckInputColor: '#ddd',
+ disabledInputColor: '#808080',
+
+ //
+ // Popover
+
+ popoverTitleBackgroundColor: '#424242',
+ popoverTitleBorderColor: '#2a2a2a',
+ popoverBodyBackgroundColor: '#2a2a2a',
+ popoverShadowColor: 'rgba(0, 0, 0, 0.2)',
+ popoverArrowBorderColor: '#2a2a2a',
+
+ popoverTitleBackgroundInverseColor: '#595959',
+ popoverTitleBorderInverseColor: '#707070',
+ popoverShadowInverseColor: 'rgba(0, 0, 0, 0.2)',
+ popoverArrowBorderInverseColor: 'rgba(58, 63, 81, 0.75)',
+
+ //
+ // Calendar
+
+ calendarTodayBackgroundColor: '#3e3e3e',
+ calendarBackgroundColor: '#2a2a2a',
+ calendarBorderColor: '#393f45',
+ calendarTextDim: '#eee',
+ calendarTextDimAlternate: '#fff',
+
+ //
+ // Table
+
+ tableRowHoverBackgroundColor: 'rgba(255, 255, 255, 0.08)',
+
+ //
+ // Srtist
+
+ addArtistBackgroundColor: '#2a2a2a',
+ artistBackgroundColor: '#2a2a2a',
+ searchIconContainerBackgroundColor: '#2b2b2b',
+ collapseButtonBackgroundColor: '#2a2a2a',
+
+ //
+ // Album
+
+ albumBackgroundColor: '#424242',
+ trackBackgroundColor: '#2a2a2a',
+
+ //
+ // misc
+
+ progressBarFrontTextColor: white,
+ progressBarBackTextColor: white,
+ progressBarBackgroundColor: '#727070',
+ logEventsBackgroundColor: '#2a2a2a'
+};
diff --git a/frontend/src/Styles/Themes/index.js b/frontend/src/Styles/Themes/index.js
new file mode 100644
index 000000000..4dec39164
--- /dev/null
+++ b/frontend/src/Styles/Themes/index.js
@@ -0,0 +1,11 @@
+import * as dark from './dark';
+import * as light from './light';
+
+const defaultDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+const auto = defaultDark ? dark : light;
+
+export default {
+ auto,
+ light,
+ dark
+};
diff --git a/frontend/src/Styles/Themes/light.js b/frontend/src/Styles/Themes/light.js
new file mode 100644
index 000000000..ccf5dcea6
--- /dev/null
+++ b/frontend/src/Styles/Themes/light.js
@@ -0,0 +1,235 @@
+const lidarrGreen = '#00A65B';
+const darkGray = '#888';
+const gray = '#adadad';
+const black = '#000';
+const white = '#fff';
+const offWhite = '#f5f7fa';
+const purple = '#7a43b6';
+const pink = '#ff69b4';
+const lightGray = '#ddd';
+
+module.exports = {
+ textColor: '#515253',
+ defaultColor: '#333',
+ disabledColor: '#999',
+ dimColor: '#555',
+ black,
+ white,
+ offWhite,
+ blue: '#06f',
+ primaryColor: '#5d9cec',
+ selectedColor: '#f9be03',
+ successColor: '#00853d',
+ dangerColor: '#f05050',
+ warningColor: '#ffa500',
+ infoColor: lidarrGreen,
+ purple,
+ pink,
+ lidarrGreen,
+ helpTextColor: '#909293',
+ darkGray,
+ gray,
+ lightGray,
+
+ // Theme Colors
+
+ themeBlue: lidarrGreen,
+ themeAlternateBlue: '#00a65b',
+ themeRed: '#c4273c',
+ themeDarkColor: '#353535',
+ themeLightColor: '#1d563d',
+ pageBackground: '#f5f7fa',
+ pageFooterBackground: '#f1f1f1',
+
+ torrentColor: '#00853d',
+ usenetColor: '#17b1d9',
+
+ // Labels
+ inverseLabelColor: '#ddd',
+ inverseLabelTextColor: '#333',
+ disabledLabelColor: '#999',
+ infoTextColor: white,
+
+ // Links
+ defaultLinkHoverColor: '#fff',
+ linkColor: '#0b8750',
+ linkHoverColor: '#1b72e2',
+
+ // Header
+ pageHeaderBackgroundColor: lidarrGreen,
+
+ // Sidebar
+
+ sidebarColor: '#e1e2e3',
+ sidebarBackgroundColor: '#353535',
+ sidebarActiveBackgroundColor: '#252525',
+
+ // Toolbar
+ toolbarColor: '#e1e2e3',
+ toolbarBackgroundColor: '#1d563d',
+ toolbarMenuItemBackgroundColor: '#4D8069',
+ toolbarMenuItemHoverBackgroundColor: '#353535',
+ toolbarLabelColor: '#8895aa',
+
+ // Accents
+ borderColor: '#e5e5e5',
+ inputBorderColor: '#dde6e9',
+ inputBoxShadowColor: 'rgba(0, 0, 0, 0.075)',
+ inputFocusBorderColor: '#66afe9',
+ inputFocusBoxShadowColor: 'rgba(102, 175, 233, 0.6)',
+ inputErrorBorderColor: '#f05050',
+ inputErrorBoxShadowColor: 'rgba(240, 80, 80, 0.6)',
+ inputWarningBorderColor: '#ffa500',
+ inputWarningBoxShadowColor: 'rgba(255, 165, 0, 0.6)',
+ colorImpairedGradient: '#ffffff',
+ colorImpairedGradientDark: '#f4f5f6',
+
+ //
+ // Buttons
+
+ defaultButtonTextColor: '#eee',
+ defaultBackgroundColor: '#fff',
+ defaultBorderColor: '#eaeaea',
+ defaultHoverBackgroundColor: '#f5f5f5',
+ defaultHoverBorderColor: '#d6d6d6',
+
+ primaryBackgroundColor: '#0b8750',
+ primaryBorderColor: '#1d563d',
+ primaryHoverBackgroundColor: '#097948',
+ primaryHoverBorderColor: '#1D563D',
+
+ successBackgroundColor: '#27c24c',
+ successBorderColor: '#26be4a',
+ successHoverBackgroundColor: '#24b145',
+ successHoverBorderColor: '#1f9c3d',
+
+ warningBackgroundColor: '#ff902b',
+ warningBorderColor: '#ff8d26',
+ warningHoverBackgroundColor: '#ff8517',
+ warningHoverBorderColor: '#fc7800',
+
+ dangerBackgroundColor: '#f05050',
+ dangerBorderColor: '#f04b4b',
+ dangerHoverBackgroundColor: '#ee3d3d',
+ dangerHoverBorderColor: '#ec2626',
+
+ iconButtonDisabledColor: '#7a7a7a',
+ iconButtonHoverColor: '#666',
+ iconButtonHoverLightColor: '#ccc',
+
+ //
+ // Modal
+
+ modalBackdropBackgroundColor: 'rgba(0, 0, 0, 0.6)',
+ modalBackgroundColor: '#fff',
+ modalCloseButtonHoverColor: '#888',
+
+ //
+ // Menu
+ menuItemColor: '#e1e2e3',
+ menuItemHoverColor: '#fbfcfc',
+ menuItemHoverBackgroundColor: '#f5f7fa',
+
+ //
+ // Toolbar
+
+ toobarButtonHoverColor: '#00A65B',
+ toobarButtonSelectedColor: '#00A65B',
+
+ //
+ // Scroller
+
+ scrollbarBackgroundColor: '#9ea4b9',
+ scrollbarHoverBackgroundColor: '#656d8c',
+
+ //
+ // Card
+
+ cardBackgroundColor: '#fff',
+ cardShadowColor: '#e1e1e1',
+ cardAlternateBackgroundColor: '#f5f5f5',
+ cardCenterBackgroundColor: '#fff',
+
+ //
+ // Alert
+
+ alertDangerBorderColor: '#ebccd1',
+ alertDangerBackgroundColor: '#f2dede',
+ alertDangerColor: '#a94442',
+
+ alertInfoBorderColor: '#bce8f1',
+ alertInfoBackgroundColor: '#d9edf7',
+ alertInfoColor: '#31708f',
+
+ alertSuccessBorderColor: '#d6e9c6',
+ alertSuccessBackgroundColor: '#dff0d8',
+ alertSuccessColor: '#3c763d',
+
+ alertWarningBorderColor: '#faebcc',
+ alertWarningBackgroundColor: '#fcf8e3',
+ alertWarningColor: '#8a6d3b',
+
+ //
+ // Slider
+
+ sliderAccentColor: '#0b8750',
+
+ //
+ // Form
+
+ inputBackgroundColor: '#fff',
+ inputReadOnlyBackgroundColor: '#eee',
+ inputHoverBackgroundColor: '#f8f8f8',
+ inputSelectedBackgroundColor: '#e2e2e2',
+ advancedFormLabelColor: '#ff902b',
+ disabledCheckInputColor: '#ddd',
+ disabledInputColor: '#808080',
+
+ //
+ // Popover
+
+ popoverTitleBackgroundColor: '#f7f7f7',
+ popoverTitleBorderColor: '#ebebeb',
+ popoverBodyBackgroundColor: '#e9e9e9',
+ popoverShadowColor: 'rgba(0, 0, 0, 0.2)',
+ popoverArrowBorderColor: '#fff',
+
+ popoverTitleBackgroundInverseColor: '#3a3f51',
+ popoverTitleBorderInverseColor: '#353535',
+ popoverShadowInverseColor: 'rgba(0, 0, 0, 0.2)',
+ popoverArrowBorderInverseColor: 'rgba(58, 63, 81, 0.75)',
+
+ //
+ // Calendar
+
+ calendarTodayBackgroundColor: '#c5c5c5',
+ calendarBackgroundColor: '#e4eaec',
+ calendarBorderColor: '#cecece',
+
+ //
+ // Table
+
+ tableRowHoverBackgroundColor: '#fafbfc',
+
+ //
+ // Atist
+
+ addArtistBackgroundColor: '#ededed',
+ artistBackgroundColor: '#ededed',
+ searchIconContainerBackgroundColor: offWhite,
+ collapseButtonBackgroundColor: offWhite,
+
+ //
+ // Album
+
+ albumBackgroundColor: white,
+ trackBackgroundColor: offWhite,
+
+ //
+ // misc
+
+ progressBarFrontTextColor: white,
+ progressBarBackTextColor: darkGray,
+ progressBarBackgroundColor: white,
+ logEventsBackgroundColor: white
+};
diff --git a/frontend/src/Styles/Variables/animations.js b/frontend/src/Styles/Variables/animations.js
new file mode 100644
index 000000000..52d12827a
--- /dev/null
+++ b/frontend/src/Styles/Variables/animations.js
@@ -0,0 +1,8 @@
+// Use CommonJS since this is consumed by PostCSS via webpack (node.js).
+
+module.exports = {
+ // Durations
+ defaultSpeed: '0.2s',
+ slowSpeed: '0.6s',
+ fastSpeed: '0.1s'
+};
diff --git a/frontend/src/Styles/Variables/dimensions.js b/frontend/src/Styles/Variables/dimensions.js
new file mode 100644
index 000000000..a704270ff
--- /dev/null
+++ b/frontend/src/Styles/Variables/dimensions.js
@@ -0,0 +1,53 @@
+module.exports = {
+ // Page
+ pageContentBodyPadding: '20px',
+ pageContentBodyPaddingSmallScreen: '10px',
+
+ // Header
+ headerHeight: '60px',
+
+ // Sidebar
+ sidebarWidth: '210px',
+
+ // Toolbar
+ toolbarHeight: '60px',
+ toolbarButtonWidth: '60px',
+ toolbarSeparatorMargin: '20px',
+
+ // Break Points
+ breakpointExtraSmall: '480px',
+ breakpointSmall: '768px',
+ breakpointMedium: '992px',
+ breakpointLarge: '1310px',
+ breakpointExtraLarge: '1450px',
+
+ // Form
+ formGroupExtraSmallWidth: '550px',
+ formGroupSmallWidth: '650px',
+ formGroupMediumWidth: '800px',
+ formGroupLargeWidth: '1200px',
+ formLabelSmallWidth: '150px',
+ formLabelLargeWidth: '250px',
+ formLabelRightMarginWidth: '20px',
+
+ // Drag
+ dragHandleWidth: '40px',
+ qualityProfileItemHeight: '30px',
+ qualityProfileItemDragSourcePadding: '4px',
+
+ // Progress Bar
+ progressBarSmallHeight: '5px',
+ progressBarMediumHeight: '15px',
+ progressBarLargeHeight: '20px',
+
+ // Jump Bar
+ jumpBarItemHeight: '25px',
+
+ // Modal
+ modalBodyPadding: '30px',
+
+ // Artist
+ artistIndexColumnPadding: '10px',
+ artistIndexColumnPaddingSmallScreen: '5px',
+ artistIndexOverviewInfoRowHeight: '21px'
+};
diff --git a/frontend/src/Styles/Variables/fonts.js b/frontend/src/Styles/Variables/fonts.js
new file mode 100644
index 000000000..def48f28e
--- /dev/null
+++ b/frontend/src/Styles/Variables/fonts.js
@@ -0,0 +1,14 @@
+module.exports = {
+ // Families
+ defaultFontFamily: 'Roboto, "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif',
+ monoSpaceFontFamily: '"Ubuntu Mono", Menlo, Monaco, Consolas, "Courier New", monospace;',
+
+ // Sizes
+ extraSmallFontSize: '11px',
+ smallFontSize: '12px',
+ defaultFontSize: '14px',
+ intermediateFontSize: '15px',
+ largeFontSize: '16px',
+
+ lineHeight: '1.528571429'
+};
diff --git a/frontend/src/Styles/Variables/zIndexes.js b/frontend/src/Styles/Variables/zIndexes.js
new file mode 100644
index 000000000..4d10253a7
--- /dev/null
+++ b/frontend/src/Styles/Variables/zIndexes.js
@@ -0,0 +1,5 @@
+module.exports = {
+ pageJumpBarZIndex: 10,
+ modalZIndex: 1000,
+ popperZIndex: 2000
+};
diff --git a/frontend/src/Styles/globals.css b/frontend/src/Styles/globals.css
new file mode 100644
index 000000000..e630c77b9
--- /dev/null
+++ b/frontend/src/Styles/globals.css
@@ -0,0 +1,6 @@
+/* stylelint-disable */
+
+@import "~normalize.css/normalize.css";
+@import "scaffolding.css";
+
+/* stylelint-enable */
\ No newline at end of file
diff --git a/frontend/src/Styles/scaffolding.css b/frontend/src/Styles/scaffolding.css
new file mode 100644
index 000000000..3f5f0fa65
--- /dev/null
+++ b/frontend/src/Styles/scaffolding.css
@@ -0,0 +1,54 @@
+/* stylelint-disable */
+* {
+ box-sizing: border-box;
+}
+
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+*:focus {
+ outline: none;
+}
+/* stylelint-enable */
+
+html,
+body {
+ color: var(--textColor);
+ font-family: 'Roboto', 'open sans', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
+}
+
+body {
+ font-size: 14px;
+ line-height: 1.528571429; /* 20/14 */
+}
+
+/* Override normalize */
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ margin: 0;
+ font-size: inherit;
+ font-family: inherit;
+ line-height: 1.528571429; /* 20/14 */
+}
+
+/* Better defaults for unordererd lists */
+
+ul {
+ margin: 0;
+ padding-left: 20px;
+}
+
+@media only screen and (min-device-width: 375px) and (max-device-width: 812px) {
+ input,
+ optgroup,
+ select,
+ textarea {
+ font-size: 16px;
+ }
+}
diff --git a/frontend/src/System/Backup/BackupRow.css b/frontend/src/System/Backup/BackupRow.css
new file mode 100644
index 000000000..db805650e
--- /dev/null
+++ b/frontend/src/System/Backup/BackupRow.css
@@ -0,0 +1,12 @@
+.type {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 20px;
+ text-align: center;
+}
+
+.actions {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 70px;
+}
diff --git a/frontend/src/System/Backup/BackupRow.css.d.ts b/frontend/src/System/Backup/BackupRow.css.d.ts
new file mode 100644
index 000000000..d758c14ca
--- /dev/null
+++ b/frontend/src/System/Backup/BackupRow.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'actions': string;
+ 'type': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/System/Backup/BackupRow.js b/frontend/src/System/Backup/BackupRow.js
new file mode 100644
index 000000000..f053ef741
--- /dev/null
+++ b/frontend/src/System/Backup/BackupRow.js
@@ -0,0 +1,161 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import Link from 'Components/Link/Link';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableRow from 'Components/Table/TableRow';
+import { icons, kinds } from 'Helpers/Props';
+import formatBytes from 'Utilities/Number/formatBytes';
+import translate from 'Utilities/String/translate';
+import RestoreBackupModalConnector from './RestoreBackupModalConnector';
+import styles from './BackupRow.css';
+
+class BackupRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isRestoreModalOpen: false,
+ isConfirmDeleteModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onRestorePress = () => {
+ this.setState({ isRestoreModalOpen: true });
+ };
+
+ onRestoreModalClose = () => {
+ this.setState({ isRestoreModalOpen: false });
+ };
+
+ onDeletePress = () => {
+ this.setState({ isConfirmDeleteModalOpen: true });
+ };
+
+ onConfirmDeleteModalClose = () => {
+ this.setState({ isConfirmDeleteModalOpen: false });
+ };
+
+ onConfirmDeletePress = () => {
+ const {
+ id,
+ onDeleteBackupPress
+ } = this.props;
+
+ this.setState({ isConfirmDeleteModalOpen: false }, () => {
+ onDeleteBackupPress(id);
+ });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ type,
+ name,
+ path,
+ size,
+ time
+ } = this.props;
+
+ const {
+ isRestoreModalOpen,
+ isConfirmDeleteModalOpen
+ } = this.state;
+
+ let iconClassName = icons.SCHEDULED;
+ let iconTooltip = translate('Scheduled');
+
+ if (type === 'manual') {
+ iconClassName = icons.INTERACTIVE;
+ iconTooltip = translate('Manual');
+ } else if (type === 'update') {
+ iconClassName = icons.UPDATE;
+ iconTooltip = translate('BeforeUpdate');
+ }
+
+ return (
+
+
+ {
+
+ }
+
+
+
+
+ {name}
+
+
+
+
+ {formatBytes(size)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+BackupRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ type: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ path: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ time: PropTypes.string.isRequired,
+ onDeleteBackupPress: PropTypes.func.isRequired
+};
+
+export default BackupRow;
diff --git a/frontend/src/System/Backup/Backups.js b/frontend/src/System/Backup/Backups.js
new file mode 100644
index 000000000..8f7a5b0a5
--- /dev/null
+++ b/frontend/src/System/Backup/Backups.js
@@ -0,0 +1,179 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Alert from 'Components/Alert';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import { icons, kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import BackupRow from './BackupRow';
+import RestoreBackupModalConnector from './RestoreBackupModalConnector';
+
+const columns = [
+ {
+ name: 'type',
+ isVisible: true
+ },
+ {
+ name: 'name',
+ label: () => translate('Name'),
+ isVisible: true
+ },
+ {
+ name: 'size',
+ label: () => translate('Size'),
+ isVisible: true
+ },
+ {
+ name: 'time',
+ label: () => translate('Time'),
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ isVisible: true
+ }
+];
+
+class Backups extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isRestoreModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onRestorePress = () => {
+ this.setState({ isRestoreModalOpen: true });
+ };
+
+ onRestoreModalClose = () => {
+ this.setState({ isRestoreModalOpen: false });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ backupExecuting,
+ onBackupPress,
+ onDeleteBackupPress
+ } = this.props;
+
+ const hasBackups = isPopulated && !!items.length;
+ const noBackups = isPopulated && !items.length;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+
+ {translate('UnableToLoadBackups')}
+
+ }
+
+ {
+ noBackups &&
+
+ {translate('NoBackupsAreAvailable')}
+
+ }
+
+ {
+ hasBackups &&
+
+
+ {
+ items.map((item) => {
+ const {
+ id,
+ type,
+ name,
+ path,
+ size,
+ time
+ } = item;
+
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+
+
+
+ );
+ }
+
+}
+
+Backups.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.array.isRequired,
+ backupExecuting: PropTypes.bool.isRequired,
+ onBackupPress: PropTypes.func.isRequired,
+ onDeleteBackupPress: PropTypes.func.isRequired
+};
+
+export default Backups;
diff --git a/frontend/src/System/Backup/BackupsConnector.js b/frontend/src/System/Backup/BackupsConnector.js
new file mode 100644
index 000000000..1353b6196
--- /dev/null
+++ b/frontend/src/System/Backup/BackupsConnector.js
@@ -0,0 +1,84 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import * as commandNames from 'Commands/commandNames';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { deleteBackup, fetchBackups } from 'Store/Actions/systemActions';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import Backups from './Backups';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.backups,
+ createCommandExecutingSelector(commandNames.BACKUP),
+ (backups, backupExecuting) => {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items
+ } = backups;
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ backupExecuting
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchFetchBackups() {
+ dispatch(fetchBackups());
+ },
+
+ onDeleteBackupPress(id) {
+ dispatch(deleteBackup({ id }));
+ },
+
+ onBackupPress() {
+ dispatch(executeCommand({
+ name: commandNames.BACKUP
+ }));
+ }
+ };
+}
+
+class BackupsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchBackups();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.backupExecuting && !this.props.backupExecuting) {
+ this.props.dispatchFetchBackups();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+BackupsConnector.propTypes = {
+ backupExecuting: PropTypes.bool.isRequired,
+ dispatchFetchBackups: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(BackupsConnector);
diff --git a/frontend/src/System/Backup/RestoreBackupModal.js b/frontend/src/System/Backup/RestoreBackupModal.js
new file mode 100644
index 000000000..48dad4d2a
--- /dev/null
+++ b/frontend/src/System/Backup/RestoreBackupModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import RestoreBackupModalContentConnector from './RestoreBackupModalContentConnector';
+
+function RestoreBackupModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+RestoreBackupModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default RestoreBackupModal;
diff --git a/frontend/src/System/Backup/RestoreBackupModalConnector.js b/frontend/src/System/Backup/RestoreBackupModalConnector.js
new file mode 100644
index 000000000..98cbcd11b
--- /dev/null
+++ b/frontend/src/System/Backup/RestoreBackupModalConnector.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import { clearRestoreBackup } from 'Store/Actions/systemActions';
+import RestoreBackupModal from './RestoreBackupModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onModalClose() {
+ dispatch(clearRestoreBackup());
+
+ props.onModalClose();
+ }
+ };
+}
+
+export default connect(null, createMapDispatchToProps)(RestoreBackupModal);
diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.css b/frontend/src/System/Backup/RestoreBackupModalContent.css
new file mode 100644
index 000000000..2775e8e08
--- /dev/null
+++ b/frontend/src/System/Backup/RestoreBackupModalContent.css
@@ -0,0 +1,24 @@
+.additionalInfo {
+ flex-grow: 1;
+ color: #777;
+}
+
+.steps {
+ margin-top: 20px;
+}
+
+.step {
+ display: flex;
+ font-size: $largeFontSize;
+ line-height: 20px;
+}
+
+.stepState {
+ margin-right: 8px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ composes: modalFooter from '~Components/Modal/ModalFooter.css';
+
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.css.d.ts b/frontend/src/System/Backup/RestoreBackupModalContent.css.d.ts
new file mode 100644
index 000000000..6603c6533
--- /dev/null
+++ b/frontend/src/System/Backup/RestoreBackupModalContent.css.d.ts
@@ -0,0 +1,10 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'additionalInfo': string;
+ 'step': string;
+ 'stepState': string;
+ 'steps': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.js b/frontend/src/System/Backup/RestoreBackupModalContent.js
new file mode 100644
index 000000000..d6cfb0c81
--- /dev/null
+++ b/frontend/src/System/Backup/RestoreBackupModalContent.js
@@ -0,0 +1,239 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TextInput from 'Components/Form/TextInput';
+import Icon from 'Components/Icon';
+import Button from 'Components/Link/Button';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import { icons, kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import styles from './RestoreBackupModalContent.css';
+
+function getErrorMessage(error) {
+ if (!error || !error.responseJSON || !error.responseJSON.message) {
+ return translate('ErrorRestoringBackup');
+ }
+
+ return error.responseJSON.message;
+}
+
+function getStepIconProps(isExecuting, hasExecuted, error) {
+ if (isExecuting) {
+ return {
+ name: icons.SPINNER,
+ isSpinning: true
+ };
+ }
+
+ if (hasExecuted) {
+ return {
+ name: icons.CHECK,
+ kind: kinds.SUCCESS
+ };
+ }
+
+ if (error) {
+ return {
+ name: icons.FATAL,
+ kinds: kinds.DANGER,
+ title: getErrorMessage(error)
+ };
+ }
+
+ return {
+ name: icons.PENDING
+ };
+}
+
+class RestoreBackupModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ file: null,
+ path: '',
+ isRestored: false,
+ isRestarted: false,
+ isReloading: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isRestoring,
+ restoreError,
+ isRestarting,
+ dispatchRestart
+ } = this.props;
+
+ if (prevProps.isRestoring && !isRestoring && !restoreError) {
+ this.setState({ isRestored: true }, () => {
+ dispatchRestart();
+ });
+ }
+
+ if (prevProps.isRestarting && !isRestarting) {
+ this.setState({
+ isRestarted: true,
+ isReloading: true
+ }, () => {
+ location.reload();
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onPathChange = ({ value, files }) => {
+ this.setState({
+ file: files[0],
+ path: value
+ });
+ };
+
+ onRestorePress = () => {
+ const {
+ id,
+ onRestorePress
+ } = this.props;
+
+ onRestorePress({
+ id,
+ file: this.state.file
+ });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ isRestoring,
+ restoreError,
+ isRestarting,
+ onModalClose
+ } = this.props;
+
+ const {
+ path,
+ isRestored,
+ isRestarted,
+ isReloading
+ } = this.state;
+
+ const isRestoreDisabled = (
+ (!id && !path) ||
+ isRestoring ||
+ isRestarting ||
+ isReloading
+ );
+
+ return (
+
+
+ Restore Backup
+
+
+
+ {
+ !!id && translate('WouldYouLikeToRestoreBackup', { name })
+ }
+
+ {
+ !id &&
+
+ }
+
+
+
+
+
+
+
+
+ {translate('Restore')}
+
+
+
+
+
+
+
+
+
+ {translate('Restart')}
+
+
+
+
+
+
+
+
+
+ {translate('Reload')}
+
+
+
+
+
+
+
+ {translate('RestoreBackupAdditionalInfo')}
+
+
+
+ {translate('Cancel')}
+
+
+
+ {translate('Restore')}
+
+
+
+ );
+ }
+}
+
+RestoreBackupModalContent.propTypes = {
+ id: PropTypes.number,
+ name: PropTypes.string,
+ path: PropTypes.string,
+ isRestoring: PropTypes.bool.isRequired,
+ restoreError: PropTypes.object,
+ isRestarting: PropTypes.bool.isRequired,
+ dispatchRestart: PropTypes.func.isRequired,
+ onRestorePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default RestoreBackupModalContent;
diff --git a/frontend/src/System/Backup/RestoreBackupModalContentConnector.js b/frontend/src/System/Backup/RestoreBackupModalContentConnector.js
new file mode 100644
index 000000000..d408d0f50
--- /dev/null
+++ b/frontend/src/System/Backup/RestoreBackupModalContentConnector.js
@@ -0,0 +1,37 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { restart, restoreBackup } from 'Store/Actions/systemActions';
+import RestoreBackupModalContent from './RestoreBackupModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.backups,
+ (state) => state.app.isRestarting,
+ (backups, isRestarting) => {
+ const {
+ isRestoring,
+ restoreError
+ } = backups;
+
+ return {
+ isRestoring,
+ restoreError,
+ isRestarting
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onRestorePress(payload) {
+ dispatch(restoreBackup(payload));
+ },
+
+ dispatchRestart() {
+ dispatch(restart());
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(RestoreBackupModalContent);
diff --git a/frontend/src/System/Events/LogsTable.js b/frontend/src/System/Events/LogsTable.js
new file mode 100644
index 000000000..1c37a03ba
--- /dev/null
+++ b/frontend/src/System/Events/LogsTable.js
@@ -0,0 +1,141 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Alert from 'Components/Alert';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
+import TablePager from 'Components/Table/TablePager';
+import { align, icons, kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import LogsTableRow from './LogsTableRow';
+
+function LogsTable(props) {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ columns,
+ selectedFilterKey,
+ filters,
+ totalRecords,
+ clearLogExecuting,
+ onRefreshPress,
+ onClearLogsPress,
+ onFilterSelect,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ isPopulated && !error && !items.length &&
+
+ {translate('NoEventsFound')}
+
+ }
+
+ {
+ isPopulated && !error && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ }
+
+
+ );
+}
+
+LogsTable.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ selectedFilterKey: PropTypes.string.isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ totalRecords: PropTypes.number,
+ clearLogExecuting: PropTypes.bool.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onRefreshPress: PropTypes.func.isRequired,
+ onClearLogsPress: PropTypes.func.isRequired
+};
+
+export default LogsTable;
diff --git a/frontend/src/System/Events/LogsTableConnector.js b/frontend/src/System/Events/LogsTableConnector.js
new file mode 100644
index 000000000..a717cba15
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableConnector.js
@@ -0,0 +1,148 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import * as commandNames from 'Commands/commandNames';
+import withCurrentPage from 'Components/withCurrentPage';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as systemActions from 'Store/Actions/systemActions';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import LogsTable from './LogsTable';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.logs,
+ createCommandExecutingSelector(commandNames.CLEAR_LOGS),
+ (logs, clearLogExecuting) => {
+ return {
+ clearLogExecuting,
+ ...logs
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ executeCommand,
+ ...systemActions
+};
+
+class LogsTableConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ useCurrentPage,
+ fetchLogs,
+ gotoLogsFirstPage
+ } = this.props;
+
+ if (useCurrentPage) {
+ fetchLogs();
+ } else {
+ gotoLogsFirstPage();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.clearLogExecuting && !this.props.clearLogExecuting) {
+ this.props.gotoLogsFirstPage();
+ }
+ }
+
+ //
+ // Listeners
+
+ onFirstPagePress = () => {
+ this.props.gotoLogsFirstPage();
+ };
+
+ onPreviousPagePress = () => {
+ this.props.gotoLogsPreviousPage();
+ };
+
+ onNextPagePress = () => {
+ this.props.gotoLogsNextPage();
+ };
+
+ onLastPagePress = () => {
+ this.props.gotoLogsLastPage();
+ };
+
+ onPageSelect = (page) => {
+ this.props.gotoLogsPage({ page });
+ };
+
+ onSortPress = (sortKey) => {
+ this.props.setLogsSort({ sortKey });
+ };
+
+ onFilterSelect = (selectedFilterKey) => {
+ this.props.setLogsFilter({ selectedFilterKey });
+ };
+
+ onTableOptionChange = (payload) => {
+ this.props.setLogsTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoLogsFirstPage();
+ }
+ };
+
+ onRefreshPress = () => {
+ this.props.gotoLogsFirstPage();
+ };
+
+ onClearLogsPress = () => {
+ this.props.executeCommand({
+ name: commandNames.CLEAR_LOGS,
+ commandFinished: this.onCommandFinished
+ });
+ };
+
+ onCommandFinished = () => {
+ this.props.gotoLogsFirstPage();
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+LogsTableConnector.propTypes = {
+ useCurrentPage: PropTypes.bool.isRequired,
+ clearLogExecuting: PropTypes.bool.isRequired,
+ fetchLogs: PropTypes.func.isRequired,
+ gotoLogsFirstPage: PropTypes.func.isRequired,
+ gotoLogsPreviousPage: PropTypes.func.isRequired,
+ gotoLogsNextPage: PropTypes.func.isRequired,
+ gotoLogsLastPage: PropTypes.func.isRequired,
+ gotoLogsPage: PropTypes.func.isRequired,
+ setLogsSort: PropTypes.func.isRequired,
+ setLogsFilter: PropTypes.func.isRequired,
+ setLogsTableOption: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default withCurrentPage(
+ connect(createMapStateToProps, mapDispatchToProps)(LogsTableConnector)
+);
diff --git a/frontend/src/System/Events/LogsTableDetailsModal.css b/frontend/src/System/Events/LogsTableDetailsModal.css
new file mode 100644
index 000000000..3e1333dec
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableDetailsModal.css
@@ -0,0 +1,17 @@
+.detailsText {
+ composes: scroller from '~Components/Scroller/Scroller.css';
+
+ display: block;
+ margin: 0 0 10.5px;
+ padding: 10px;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ background-color: var(--logEventsBackgroundColor);
+ color: var(--textColor);
+ white-space: pre;
+ word-wrap: break-word;
+ word-break: break-all;
+ font-size: 13px;
+ font-family: $monoSpaceFontFamily;
+ line-height: 1.52857143;
+}
diff --git a/frontend/src/System/Events/LogsTableDetailsModal.css.d.ts b/frontend/src/System/Events/LogsTableDetailsModal.css.d.ts
new file mode 100644
index 000000000..09b8fe302
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableDetailsModal.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'detailsText': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/System/Events/LogsTableDetailsModal.js b/frontend/src/System/Events/LogsTableDetailsModal.js
new file mode 100644
index 000000000..87dc4a4ee
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableDetailsModal.js
@@ -0,0 +1,79 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Button from 'Components/Link/Button';
+import Modal from 'Components/Modal/Modal';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import Scroller from 'Components/Scroller/Scroller';
+import { scrollDirections } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import styles from './LogsTableDetailsModal.css';
+
+function LogsTableDetailsModal(props) {
+ const {
+ isOpen,
+ message,
+ exception,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ Details
+
+
+
+
+ {translate('Message')}
+
+
+
+ {message}
+
+
+ {
+ !!exception &&
+
+
+ {translate('Exception')}
+
+
+ {exception}
+
+
+ }
+
+
+
+
+ Close
+
+
+
+
+ );
+}
+
+LogsTableDetailsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ message: PropTypes.string.isRequired,
+ exception: PropTypes.string,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default LogsTableDetailsModal;
diff --git a/frontend/src/System/Events/LogsTableRow.css b/frontend/src/System/Events/LogsTableRow.css
new file mode 100644
index 000000000..0c5a3e1c6
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableRow.css
@@ -0,0 +1,35 @@
+.level {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 20px;
+}
+
+.info {
+ color: #1e90ff;
+}
+
+.debug {
+ color: #808080;
+}
+
+.trace {
+ color: #d3d3d3;
+}
+
+.warn {
+ color: var(--warningColor);
+}
+
+.error {
+ color: var(--dangerColor);
+}
+
+.fatal {
+ color: var(--purple);
+}
+
+.actions {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 45px;
+}
diff --git a/frontend/src/System/Events/LogsTableRow.css.d.ts b/frontend/src/System/Events/LogsTableRow.css.d.ts
new file mode 100644
index 000000000..c0267e838
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableRow.css.d.ts
@@ -0,0 +1,14 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'actions': string;
+ 'debug': string;
+ 'error': string;
+ 'fatal': string;
+ 'info': string;
+ 'level': string;
+ 'trace': string;
+ 'warn': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/System/Events/LogsTableRow.js b/frontend/src/System/Events/LogsTableRow.js
new file mode 100644
index 000000000..8d2d5f541
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableRow.js
@@ -0,0 +1,157 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Icon from 'Components/Icon';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableRowButton from 'Components/Table/TableRowButton';
+import { icons } from 'Helpers/Props';
+import LogsTableDetailsModal from './LogsTableDetailsModal';
+import styles from './LogsTableRow.css';
+
+function getIconName(level) {
+ switch (level) {
+ case 'trace':
+ case 'debug':
+ case 'info':
+ return icons.INFO;
+ case 'warn':
+ return icons.DANGER;
+ case 'error':
+ return icons.BUG;
+ case 'fatal':
+ return icons.FATAL;
+ default:
+ return icons.UNKNOWN;
+ }
+}
+
+class LogsTableRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onPress = () => {
+ // Don't re-open the modal if it's already open
+ if (!this.state.isDetailsModalOpen) {
+ this.setState({ isDetailsModalOpen: true });
+ }
+ };
+
+ onModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ level,
+ time,
+ logger,
+ message,
+ exception,
+ columns
+ } = this.props;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'level') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'time') {
+ return (
+
+ );
+ }
+
+ if (name === 'logger') {
+ return (
+
+ {logger}
+
+ );
+ }
+
+ if (name === 'message') {
+ return (
+
+ {message}
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+
+
+ );
+ }
+
+}
+
+LogsTableRow.propTypes = {
+ level: PropTypes.string.isRequired,
+ time: PropTypes.string.isRequired,
+ logger: PropTypes.string.isRequired,
+ message: PropTypes.string.isRequired,
+ exception: PropTypes.string,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default LogsTableRow;
diff --git a/frontend/src/System/Logs/Files/LogFiles.js b/frontend/src/System/Logs/Files/LogFiles.js
new file mode 100644
index 000000000..5339a8590
--- /dev/null
+++ b/frontend/src/System/Logs/Files/LogFiles.js
@@ -0,0 +1,143 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Alert from 'Components/Alert';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import { icons, kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import LogsNavMenu from '../LogsNavMenu';
+import LogFilesTableRow from './LogFilesTableRow';
+
+const columns = [
+ {
+ name: 'filename',
+ label: () => translate('Filename'),
+ isVisible: true
+ },
+ {
+ name: 'lastWriteTime',
+ label: () => translate('LastWriteTime'),
+ isVisible: true
+ },
+ {
+ name: 'download',
+ isVisible: true
+ }
+];
+
+class LogFiles extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ items,
+ deleteFilesExecuting,
+ currentLogView,
+ location,
+ onRefreshPress,
+ onDeleteFilesPress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {translate('LogFilesLocation', {
+ location
+ })}
+
+
+ {currentLogView === 'Log Files' ? (
+
+
+
+ ) : null}
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+ {
+ !isFetching && !items.length &&
+
+ {translate('NoLogFiles')}
+
+ }
+
+
+ );
+ }
+
+}
+
+LogFiles.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ items: PropTypes.array.isRequired,
+ deleteFilesExecuting: PropTypes.bool.isRequired,
+ currentLogView: PropTypes.string.isRequired,
+ location: PropTypes.string.isRequired,
+ onRefreshPress: PropTypes.func.isRequired,
+ onDeleteFilesPress: PropTypes.func.isRequired
+};
+
+export default LogFiles;
diff --git a/frontend/src/System/Logs/Files/LogFilesConnector.js b/frontend/src/System/Logs/Files/LogFilesConnector.js
new file mode 100644
index 000000000..98a55f32f
--- /dev/null
+++ b/frontend/src/System/Logs/Files/LogFilesConnector.js
@@ -0,0 +1,91 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import * as commandNames from 'Commands/commandNames';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { fetchLogFiles } from 'Store/Actions/systemActions';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import combinePath from 'Utilities/String/combinePath';
+import LogFiles from './LogFiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.logFiles,
+ (state) => state.system.status.item,
+ createCommandExecutingSelector(commandNames.DELETE_LOG_FILES),
+ (logFiles, status, deleteFilesExecuting) => {
+ const {
+ isFetching,
+ items
+ } = logFiles;
+
+ const {
+ appData,
+ isWindows
+ } = status;
+
+ return {
+ isFetching,
+ items,
+ deleteFilesExecuting,
+ currentLogView: 'Log Files',
+ location: combinePath(isWindows, appData, ['logs'])
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchLogFiles,
+ executeCommand
+};
+
+class LogFilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchLogFiles();
+ }
+
+ //
+ // Listeners
+
+ onRefreshPress = () => {
+ this.props.fetchLogFiles();
+ };
+
+ onDeleteFilesPress = () => {
+ this.props.executeCommand({
+ name: commandNames.DELETE_LOG_FILES,
+ commandFinished: this.onCommandFinished
+ });
+ };
+
+ onCommandFinished = () => {
+ this.props.fetchLogFiles();
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+LogFilesConnector.propTypes = {
+ deleteFilesExecuting: PropTypes.bool.isRequired,
+ fetchLogFiles: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(LogFilesConnector);
diff --git a/frontend/src/System/Logs/Files/LogFilesTableRow.css b/frontend/src/System/Logs/Files/LogFilesTableRow.css
new file mode 100644
index 000000000..313f50cc0
--- /dev/null
+++ b/frontend/src/System/Logs/Files/LogFilesTableRow.css
@@ -0,0 +1,5 @@
+.download {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
diff --git a/frontend/src/System/Logs/Files/LogFilesTableRow.css.d.ts b/frontend/src/System/Logs/Files/LogFilesTableRow.css.d.ts
new file mode 100644
index 000000000..0a1fbfe07
--- /dev/null
+++ b/frontend/src/System/Logs/Files/LogFilesTableRow.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'download': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/System/Logs/Files/LogFilesTableRow.js b/frontend/src/System/Logs/Files/LogFilesTableRow.js
new file mode 100644
index 000000000..ef08ada4e
--- /dev/null
+++ b/frontend/src/System/Logs/Files/LogFilesTableRow.js
@@ -0,0 +1,50 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableRow from 'Components/Table/TableRow';
+import styles from './LogFilesTableRow.css';
+
+class LogFilesTableRow extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ filename,
+ lastWriteTime,
+ downloadUrl
+ } = this.props;
+
+ return (
+
+ {filename}
+
+
+
+
+
+ Download
+
+
+
+ );
+ }
+
+}
+
+LogFilesTableRow.propTypes = {
+ filename: PropTypes.string.isRequired,
+ lastWriteTime: PropTypes.string.isRequired,
+ downloadUrl: PropTypes.string.isRequired
+};
+
+export default LogFilesTableRow;
diff --git a/frontend/src/System/Logs/Logs.js b/frontend/src/System/Logs/Logs.js
new file mode 100644
index 000000000..fa0be453e
--- /dev/null
+++ b/frontend/src/System/Logs/Logs.js
@@ -0,0 +1,30 @@
+import React, { Component } from 'react';
+import { Route } from 'react-router-dom';
+import Switch from 'Components/Router/Switch';
+import LogFilesConnector from './Files/LogFilesConnector';
+import UpdateLogFilesConnector from './Updates/UpdateLogFilesConnector';
+
+class Logs extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+export default Logs;
diff --git a/frontend/src/System/Logs/LogsNavMenu.js b/frontend/src/System/Logs/LogsNavMenu.js
new file mode 100644
index 000000000..cc485f270
--- /dev/null
+++ b/frontend/src/System/Logs/LogsNavMenu.js
@@ -0,0 +1,71 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Menu from 'Components/Menu/Menu';
+import MenuButton from 'Components/Menu/MenuButton';
+import MenuContent from 'Components/Menu/MenuContent';
+import MenuItem from 'Components/Menu/MenuItem';
+
+class LogsNavMenu extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isMenuOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onMenuButtonPress = () => {
+ this.setState({ isMenuOpen: !this.state.isMenuOpen });
+ };
+
+ onMenuItemPress = () => {
+ this.setState({ isMenuOpen: false });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ current
+ } = this.props;
+
+ return (
+
+
+ {current}
+
+
+
+ Log Files
+
+
+
+ Updater Log Files
+
+
+
+ );
+ }
+}
+
+LogsNavMenu.propTypes = {
+ current: PropTypes.string.isRequired
+};
+
+export default LogsNavMenu;
diff --git a/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js b/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js
new file mode 100644
index 000000000..537816014
--- /dev/null
+++ b/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js
@@ -0,0 +1,90 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import * as commandNames from 'Commands/commandNames';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { fetchUpdateLogFiles } from 'Store/Actions/systemActions';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import combinePath from 'Utilities/String/combinePath';
+import LogFiles from '../Files/LogFiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.updateLogFiles,
+ (state) => state.system.status.item,
+ createCommandExecutingSelector(commandNames.DELETE_UPDATE_LOG_FILES),
+ (updateLogFiles, status, deleteFilesExecuting) => {
+ const {
+ isFetching,
+ items
+ } = updateLogFiles;
+
+ const {
+ appData,
+ isWindows
+ } = status;
+
+ return {
+ isFetching,
+ items,
+ deleteFilesExecuting,
+ currentLogView: 'Updater Log Files',
+ location: combinePath(isWindows, appData, ['UpdateLogs'])
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchUpdateLogFiles,
+ executeCommand
+};
+
+class UpdateLogFilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchUpdateLogFiles();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.deleteFilesExecuting && !this.props.deleteFilesExecuting) {
+ this.props.fetchUpdateLogFiles();
+ }
+ }
+
+ //
+ // Listeners
+
+ onRefreshPress = () => {
+ this.props.fetchUpdateLogFiles();
+ };
+
+ onDeleteFilesPress = () => {
+ this.props.executeCommand({ name: commandNames.DELETE_UPDATE_LOG_FILES });
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+UpdateLogFilesConnector.propTypes = {
+ deleteFilesExecuting: PropTypes.bool.isRequired,
+ fetchUpdateLogFiles: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(UpdateLogFilesConnector);
diff --git a/frontend/src/System/Status/About/About.css b/frontend/src/System/Status/About/About.css
new file mode 100644
index 000000000..9886c7ad0
--- /dev/null
+++ b/frontend/src/System/Status/About/About.css
@@ -0,0 +1,5 @@
+.descriptionList {
+ composes: descriptionList from '~Components/DescriptionList/DescriptionList.css';
+
+ margin-bottom: 10px;
+}
diff --git a/frontend/src/System/Status/About/About.css.d.ts b/frontend/src/System/Status/About/About.css.d.ts
new file mode 100644
index 000000000..34c1578a4
--- /dev/null
+++ b/frontend/src/System/Status/About/About.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'descriptionList': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/System/Status/About/About.js b/frontend/src/System/Status/About/About.js
new file mode 100644
index 000000000..8af5b4717
--- /dev/null
+++ b/frontend/src/System/Status/About/About.js
@@ -0,0 +1,128 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import FieldSet from 'Components/FieldSet';
+import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
+import titleCase from 'Utilities/String/titleCase';
+import translate from 'Utilities/String/translate';
+import StartTime from './StartTime';
+import styles from './About.css';
+
+class About extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ version,
+ packageVersion,
+ packageAuthor,
+ isNetCore,
+ isDocker,
+ runtimeVersion,
+ databaseVersion,
+ databaseType,
+ migrationVersion,
+ appData,
+ startupPath,
+ mode,
+ startTime,
+ timeFormat,
+ longDateFormat
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ packageVersion &&
+ {packageVersion} {' by '} : packageVersion)}
+ />
+ }
+
+ {
+ isNetCore &&
+
+ }
+
+ {
+ isDocker &&
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+ );
+ }
+
+}
+
+About.propTypes = {
+ version: PropTypes.string.isRequired,
+ packageVersion: PropTypes.string,
+ packageAuthor: PropTypes.string,
+ isNetCore: PropTypes.bool.isRequired,
+ runtimeVersion: PropTypes.string.isRequired,
+ isDocker: PropTypes.bool.isRequired,
+ databaseType: PropTypes.string.isRequired,
+ databaseVersion: PropTypes.string.isRequired,
+ migrationVersion: PropTypes.number.isRequired,
+ appData: PropTypes.string.isRequired,
+ startupPath: PropTypes.string.isRequired,
+ mode: PropTypes.string.isRequired,
+ startTime: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired
+};
+
+export default About;
diff --git a/frontend/src/System/Status/About/AboutConnector.js b/frontend/src/System/Status/About/AboutConnector.js
new file mode 100644
index 000000000..475d9778b
--- /dev/null
+++ b/frontend/src/System/Status/About/AboutConnector.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchStatus } from 'Store/Actions/systemActions';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import About from './About';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.status,
+ createUISettingsSelector(),
+ (status, uiSettings) => {
+ return {
+ ...status.item,
+ timeFormat: uiSettings.timeFormat,
+ longDateFormat: uiSettings.longDateFormat
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchStatus
+};
+
+class AboutConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchStatus();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AboutConnector.propTypes = {
+ fetchStatus: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AboutConnector);
diff --git a/frontend/src/System/Status/About/StartTime.js b/frontend/src/System/Status/About/StartTime.js
new file mode 100644
index 000000000..08c820add
--- /dev/null
+++ b/frontend/src/System/Status/About/StartTime.js
@@ -0,0 +1,93 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
+
+function getUptime(startTime) {
+ return formatTimeSpan(moment().diff(startTime));
+}
+
+class StartTime extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const {
+ startTime,
+ timeFormat,
+ longDateFormat
+ } = props;
+
+ this._timeoutId = null;
+
+ this.state = {
+ uptime: getUptime(startTime),
+ startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true })
+ };
+ }
+
+ componentDidMount() {
+ this._timeoutId = setTimeout(this.onTimeout, 1000);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ startTime,
+ timeFormat,
+ longDateFormat
+ } = this.props;
+
+ if (
+ startTime !== prevProps.startTime ||
+ timeFormat !== prevProps.timeFormat ||
+ longDateFormat !== prevProps.longDateFormat
+ ) {
+ this.setState({
+ uptime: getUptime(startTime),
+ startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true })
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ if (this._timeoutId) {
+ this._timeoutId = clearTimeout(this._timeoutId);
+ }
+ }
+
+ //
+ // Listeners
+
+ onTimeout = () => {
+ this.setState({ uptime: getUptime(this.props.startTime) });
+ this._timeoutId = setTimeout(this.onTimeout, 1000);
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ uptime,
+ startTime
+ } = this.state;
+
+ return (
+
+ {uptime}
+
+ );
+ }
+}
+
+StartTime.propTypes = {
+ startTime: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired
+};
+
+export default StartTime;
diff --git a/frontend/src/System/Status/DiskSpace/DiskSpace.css b/frontend/src/System/Status/DiskSpace/DiskSpace.css
new file mode 100644
index 000000000..dd92926d4
--- /dev/null
+++ b/frontend/src/System/Status/DiskSpace/DiskSpace.css
@@ -0,0 +1,5 @@
+.space {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 150px;
+}
diff --git a/frontend/src/System/Status/DiskSpace/DiskSpace.css.d.ts b/frontend/src/System/Status/DiskSpace/DiskSpace.css.d.ts
new file mode 100644
index 000000000..23a67ff58
--- /dev/null
+++ b/frontend/src/System/Status/DiskSpace/DiskSpace.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'space': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/System/Status/DiskSpace/DiskSpace.js b/frontend/src/System/Status/DiskSpace/DiskSpace.js
new file mode 100644
index 000000000..38cf9254a
--- /dev/null
+++ b/frontend/src/System/Status/DiskSpace/DiskSpace.js
@@ -0,0 +1,123 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import FieldSet from 'Components/FieldSet';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ProgressBar from 'Components/ProgressBar';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TableRow from 'Components/Table/TableRow';
+import { kinds, sizes } from 'Helpers/Props';
+import formatBytes from 'Utilities/Number/formatBytes';
+import translate from 'Utilities/String/translate';
+import styles from './DiskSpace.css';
+
+const columns = [
+ {
+ name: 'path',
+ label: () => translate('Location'),
+ isVisible: true
+ },
+ {
+ name: 'freeSpace',
+ label: () => translate('FreeSpace'),
+ isVisible: true
+ },
+ {
+ name: 'totalSpace',
+ label: () => translate('TotalSpace'),
+ isVisible: true
+ },
+ {
+ name: 'progress',
+ isVisible: true
+ }
+];
+
+class DiskSpace extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ items
+ } = this.props;
+
+ return (
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching &&
+
+
+ {
+ items.map((item) => {
+ const {
+ freeSpace,
+ totalSpace
+ } = item;
+
+ const diskUsage = Math.round(100 - freeSpace / totalSpace * 100);
+ let diskUsageKind = kinds.PRIMARY;
+
+ if (diskUsage > 90) {
+ diskUsageKind = kinds.DANGER;
+ } else if (diskUsage > 80) {
+ diskUsageKind = kinds.WARNING;
+ }
+
+ return (
+
+
+ {item.path}
+
+ {
+ item.label &&
+ ` (${item.label})`
+ }
+
+
+
+ {formatBytes(freeSpace)}
+
+
+
+ {formatBytes(totalSpace)}
+
+
+
+ = 12}
+ text={`${diskUsage}%`}
+ />
+
+
+ );
+ })
+ }
+
+
+ }
+
+ );
+ }
+
+}
+
+DiskSpace.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ items: PropTypes.array.isRequired
+};
+
+export default DiskSpace;
diff --git a/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js b/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js
new file mode 100644
index 000000000..3049b2ead
--- /dev/null
+++ b/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js
@@ -0,0 +1,54 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchDiskSpace } from 'Store/Actions/systemActions';
+import DiskSpace from './DiskSpace';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.diskSpace,
+ (diskSpace) => {
+ const {
+ isFetching,
+ items
+ } = diskSpace;
+
+ return {
+ isFetching,
+ items
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchDiskSpace
+};
+
+class DiskSpaceConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchDiskSpace();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DiskSpaceConnector.propTypes = {
+ fetchDiskSpace: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DiskSpaceConnector);
diff --git a/frontend/src/System/Status/Donations/Donations.js b/frontend/src/System/Status/Donations/Donations.js
new file mode 100644
index 000000000..b3bff8dff
--- /dev/null
+++ b/frontend/src/System/Status/Donations/Donations.js
@@ -0,0 +1,64 @@
+import React, { Component } from 'react';
+import FieldSet from 'Components/FieldSet';
+import Link from 'Components/Link/Link';
+import translate from 'Utilities/String/translate';
+import styles from '../styles.css';
+
+class Donations extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+Donations.propTypes = {
+
+};
+
+export default Donations;
diff --git a/frontend/src/System/Status/Health/Health.css b/frontend/src/System/Status/Health/Health.css
new file mode 100644
index 000000000..1c4cb6a9d
--- /dev/null
+++ b/frontend/src/System/Status/Health/Health.css
@@ -0,0 +1,20 @@
+.legend {
+ display: flex;
+ justify-content: space-between;
+}
+
+.loading {
+ composes: loading from '~Components/Loading/LoadingIndicator.css';
+
+ margin-top: 2px;
+ margin-left: 10px;
+ text-align: left;
+}
+
+.status {
+ width: 20px;
+}
+
+.healthOk {
+ margin-bottom: 25px;
+}
diff --git a/frontend/src/System/Status/Health/Health.css.d.ts b/frontend/src/System/Status/Health/Health.css.d.ts
new file mode 100644
index 000000000..f25332f00
--- /dev/null
+++ b/frontend/src/System/Status/Health/Health.css.d.ts
@@ -0,0 +1,10 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'healthOk': string;
+ 'legend': string;
+ 'loading': string;
+ 'status': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/System/Status/Health/Health.js b/frontend/src/System/Status/Health/Health.js
new file mode 100644
index 000000000..7d4eaf58e
--- /dev/null
+++ b/frontend/src/System/Status/Health/Health.js
@@ -0,0 +1,243 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Alert from 'Components/Alert';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TableRow from 'Components/Table/TableRow';
+import { icons, kinds } from 'Helpers/Props';
+import titleCase from 'Utilities/String/titleCase';
+import translate from 'Utilities/String/translate';
+import styles from './Health.css';
+
+function getInternalLink(source) {
+ switch (source) {
+ case 'IndexerRssCheck':
+ case 'IndexerSearchCheck':
+ case 'IndexerStatusCheck':
+ case 'IndexerJackettAllCheck':
+ case 'IndexerLongTermStatusCheck':
+ return (
+
+ );
+ case 'DownloadClientCheck':
+ case 'DownloadClientStatusCheck':
+ case 'ImportMechanismCheck':
+ case 'RemotePathMappingCheck':
+ return (
+
+ );
+ case 'NotificationStatusCheck':
+ return (
+
+ );
+ case 'RootFolderCheck':
+ return (
+
+ );
+ case 'UpdateCheck':
+ return (
+
+ );
+ default:
+ return;
+ }
+}
+
+function getTestLink(source, props) {
+ switch (source) {
+ case 'IndexerStatusCheck':
+ case 'IndexerLongTermStatusCheck':
+ return (
+
+ );
+ case 'DownloadClientCheck':
+ case 'DownloadClientStatusCheck':
+ return (
+
+ );
+
+ default:
+ break;
+ }
+}
+
+const columns = [
+ {
+ className: styles.status,
+ name: 'type',
+ isVisible: true
+ },
+ {
+ name: 'message',
+ label: () => translate('Message'),
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ label: () => translate('Actions'),
+ isVisible: true
+ }
+];
+
+class Health extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ items
+ } = this.props;
+
+ const healthIssues = !!items.length;
+
+ return (
+
+ Health
+
+ {
+ isFetching && isPopulated &&
+
+ }
+
+ }
+ >
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !healthIssues &&
+
+ No issues with your configuration
+
+ }
+
+ {
+ healthIssues &&
+
+
+ {
+ items.map((item) => {
+ const internalLink = getInternalLink(item.source);
+ const testLink = getTestLink(item.source, this.props);
+
+ let kind = kinds.WARNING;
+ switch (item.type.toLowerCase()) {
+ case 'error':
+ kind = kinds.DANGER;
+ break;
+ default:
+ case 'warning':
+ kind = kinds.WARNING;
+ break;
+ case 'notice':
+ kind = kinds.INFO;
+ break;
+ }
+
+ return (
+
+
+
+
+
+ {item.message}
+
+
+
+
+ {
+ internalLink
+ }
+
+ {
+ !!testLink &&
+ testLink
+ }
+
+
+ );
+ })
+ }
+
+
+ }
+ {
+ healthIssues &&
+
+
+
+ }
+
+ );
+ }
+
+}
+
+Health.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ items: PropTypes.array.isRequired,
+ isTestingAllDownloadClients: PropTypes.bool.isRequired,
+ isTestingAllIndexers: PropTypes.bool.isRequired,
+ dispatchTestAllDownloadClients: PropTypes.func.isRequired,
+ dispatchTestAllIndexers: PropTypes.func.isRequired
+};
+
+export default Health;
diff --git a/frontend/src/System/Status/Health/HealthConnector.js b/frontend/src/System/Status/Health/HealthConnector.js
new file mode 100644
index 000000000..dd13b0a9c
--- /dev/null
+++ b/frontend/src/System/Status/Health/HealthConnector.js
@@ -0,0 +1,69 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { testAllDownloadClients, testAllIndexers } from 'Store/Actions/settingsActions';
+import { fetchHealth } from 'Store/Actions/systemActions';
+import createHealthCheckSelector from 'Store/Selectors/createHealthCheckSelector';
+import Health from './Health';
+
+function createMapStateToProps() {
+ return createSelector(
+ createHealthCheckSelector(),
+ (state) => state.system.health,
+ (state) => state.settings.downloadClients.isTestingAll,
+ (state) => state.settings.indexers.isTestingAll,
+ (items, health, isTestingAllDownloadClients, isTestingAllIndexers) => {
+ const {
+ isFetching,
+ isPopulated
+ } = health;
+
+ return {
+ isFetching,
+ isPopulated,
+ items,
+ isTestingAllDownloadClients,
+ isTestingAllIndexers
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchHealth: fetchHealth,
+ dispatchTestAllDownloadClients: testAllDownloadClients,
+ dispatchTestAllIndexers: testAllIndexers
+};
+
+class HealthConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchHealth();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchFetchHealth,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+HealthConnector.propTypes = {
+ dispatchFetchHealth: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(HealthConnector);
diff --git a/frontend/src/System/Status/Health/HealthStatusConnector.js b/frontend/src/System/Status/Health/HealthStatusConnector.js
new file mode 100644
index 000000000..e609dd712
--- /dev/null
+++ b/frontend/src/System/Status/Health/HealthStatusConnector.js
@@ -0,0 +1,81 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
+import { fetchHealth } from 'Store/Actions/systemActions';
+import createHealthCheckSelector from 'Store/Selectors/createHealthCheckSelector';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app,
+ createHealthCheckSelector(),
+ (state) => state.system.health,
+ (app, items, health) => {
+ const count = items.length;
+ let errors = false;
+ let warnings = false;
+
+ items.forEach((item) => {
+ if (item.type === 'error') {
+ errors = true;
+ }
+
+ if (item.type === 'warning') {
+ warnings = true;
+ }
+ });
+
+ return {
+ isConnected: app.isConnected,
+ isReconnecting: app.isReconnecting,
+ isPopulated: health.isPopulated,
+ count,
+ errors,
+ warnings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchHealth
+};
+
+class HealthStatusConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.isPopulated) {
+ this.props.fetchHealth();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.isConnected && prevProps.isReconnecting) {
+ this.props.fetchHealth();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+HealthStatusConnector.propTypes = {
+ isConnected: PropTypes.bool.isRequired,
+ isReconnecting: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ fetchHealth: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(HealthStatusConnector);
diff --git a/frontend/src/System/Status/MoreInfo/MoreInfo.js b/frontend/src/System/Status/MoreInfo/MoreInfo.js
new file mode 100644
index 000000000..c029a6511
--- /dev/null
+++ b/frontend/src/System/Status/MoreInfo/MoreInfo.js
@@ -0,0 +1,58 @@
+import React, { Component } from 'react';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
+import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
+import FieldSet from 'Components/FieldSet';
+import Link from 'Components/Link/Link';
+import translate from 'Utilities/String/translate';
+
+class MoreInfo extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+ Home page
+
+ lidarr.audio
+
+
+ Wiki
+
+ wiki.servarr.com/lidarr
+
+
+ Reddit
+
+ /r/Lidarr
+
+
+ Discord
+
+ lidarr.audio/discord
+
+
+ Source
+
+ github.com/Lidarr/Lidarr
+
+
+ Feature Requests
+
+ github.com/Lidarr/Lidarr/issues
+
+
+
+
+ );
+ }
+}
+
+MoreInfo.propTypes = {
+
+};
+
+export default MoreInfo;
diff --git a/frontend/src/System/Status/Status.js b/frontend/src/System/Status/Status.js
new file mode 100644
index 000000000..c1f5dac3b
--- /dev/null
+++ b/frontend/src/System/Status/Status.js
@@ -0,0 +1,32 @@
+import React, { Component } from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import translate from 'Utilities/String/translate';
+import AboutConnector from './About/AboutConnector';
+import DiskSpaceConnector from './DiskSpace/DiskSpaceConnector';
+import Donations from './Donations/Donations';
+import HealthConnector from './Health/HealthConnector';
+import MoreInfo from './MoreInfo/MoreInfo';
+
+class Status extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
+
+export default Status;
diff --git a/frontend/src/System/Status/styles.css b/frontend/src/System/Status/styles.css
new file mode 100644
index 000000000..44fe69bad
--- /dev/null
+++ b/frontend/src/System/Status/styles.css
@@ -0,0 +1,17 @@
+.logo {
+ margin: auto;
+ padding: 9px;
+}
+
+.logoContainer {
+ display: inline-block;
+ margin: 0.5em;
+ width: 50px;
+ height: 50px;
+ outline: none;
+ border: solid 1px #e6e6e6;
+ border-radius: 0.5em;
+ background: #f8f8ff;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
+ cursor: pointer;
+}
diff --git a/frontend/src/System/Status/styles.css.d.ts b/frontend/src/System/Status/styles.css.d.ts
new file mode 100644
index 000000000..521c670e6
--- /dev/null
+++ b/frontend/src/System/Status/styles.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'logo': string;
+ 'logoContainer': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.css b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css
new file mode 100644
index 000000000..6e38929c9
--- /dev/null
+++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css
@@ -0,0 +1,31 @@
+.trigger {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 50px;
+}
+
+.triggerContent {
+ display: flex;
+ justify-content: space-between;
+ width: 100%;
+}
+
+.queued,
+.started,
+.ended {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 180px;
+}
+
+.duration {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
+
+.actions {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 60px;
+}
diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.css.d.ts b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css.d.ts
new file mode 100644
index 000000000..2c6010533
--- /dev/null
+++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css.d.ts
@@ -0,0 +1,13 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'actions': string;
+ 'duration': string;
+ 'ended': string;
+ 'queued': string;
+ 'started': string;
+ 'trigger': string;
+ 'triggerContent': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.tsx b/frontend/src/System/Tasks/Queued/QueuedTaskRow.tsx
new file mode 100644
index 000000000..4511bcbf4
--- /dev/null
+++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.tsx
@@ -0,0 +1,238 @@
+import moment from 'moment';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { CommandBody } from 'Commands/Command';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableRow from 'Components/Table/TableRow';
+import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
+import { icons, kinds } from 'Helpers/Props';
+import { cancelCommand } from 'Store/Actions/commandActions';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import formatDate from 'Utilities/Date/formatDate';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
+import titleCase from 'Utilities/String/titleCase';
+import translate from 'Utilities/String/translate';
+import QueuedTaskRowNameCell from './QueuedTaskRowNameCell';
+import styles from './QueuedTaskRow.css';
+
+function getStatusIconProps(status: string, message: string | undefined) {
+ const title = titleCase(status);
+
+ switch (status) {
+ case 'queued':
+ return {
+ name: icons.PENDING,
+ title,
+ };
+
+ case 'started':
+ return {
+ name: icons.REFRESH,
+ isSpinning: true,
+ title,
+ };
+
+ case 'completed':
+ return {
+ name: icons.CHECK,
+ kind: kinds.SUCCESS,
+ title: message === 'Completed' ? title : `${title}: ${message}`,
+ };
+
+ case 'failed':
+ return {
+ name: icons.FATAL,
+ kind: kinds.DANGER,
+ title: `${title}: ${message}`,
+ };
+
+ default:
+ return {
+ name: icons.UNKNOWN,
+ title,
+ };
+ }
+}
+
+function getFormattedDates(
+ queued: string,
+ started: string | undefined,
+ ended: string | undefined,
+ showRelativeDates: boolean,
+ shortDateFormat: string
+) {
+ if (showRelativeDates) {
+ return {
+ queuedAt: moment(queued).fromNow(),
+ startedAt: started ? moment(started).fromNow() : '-',
+ endedAt: ended ? moment(ended).fromNow() : '-',
+ };
+ }
+
+ return {
+ queuedAt: formatDate(queued, shortDateFormat),
+ startedAt: started ? formatDate(started, shortDateFormat) : '-',
+ endedAt: ended ? formatDate(ended, shortDateFormat) : '-',
+ };
+}
+
+interface QueuedTimes {
+ queuedAt: string;
+ startedAt: string;
+ endedAt: string;
+}
+
+export interface QueuedTaskRowProps {
+ id: number;
+ trigger: string;
+ commandName: string;
+ queued: string;
+ started?: string;
+ ended?: string;
+ status: string;
+ duration?: string;
+ message?: string;
+ body: CommandBody;
+ clientUserAgent?: string;
+}
+
+export default function QueuedTaskRow(props: QueuedTaskRowProps) {
+ const {
+ id,
+ trigger,
+ commandName,
+ queued,
+ started,
+ ended,
+ status,
+ duration,
+ message,
+ body,
+ clientUserAgent,
+ } = props;
+
+ const dispatch = useDispatch();
+ const { longDateFormat, shortDateFormat, showRelativeDates, timeFormat } =
+ useSelector(createUISettingsSelector());
+
+ const updateTimeTimeoutId = useRef | null>(
+ null
+ );
+ const [times, setTimes] = useState(
+ getFormattedDates(
+ queued,
+ started,
+ ended,
+ showRelativeDates,
+ shortDateFormat
+ )
+ );
+
+ const [
+ isCancelConfirmModalOpen,
+ openCancelConfirmModal,
+ closeCancelConfirmModal,
+ ] = useModalOpenState(false);
+
+ const handleCancelPress = useCallback(() => {
+ dispatch(cancelCommand({ id }));
+ }, [id, dispatch]);
+
+ useEffect(() => {
+ updateTimeTimeoutId.current = setTimeout(() => {
+ setTimes(
+ getFormattedDates(
+ queued,
+ started,
+ ended,
+ showRelativeDates,
+ shortDateFormat
+ )
+ );
+ }, 30000);
+
+ return () => {
+ if (updateTimeTimeoutId.current) {
+ clearTimeout(updateTimeTimeoutId.current);
+ }
+ };
+ }, [queued, started, ended, showRelativeDates, shortDateFormat, setTimes]);
+
+ const { queuedAt, startedAt, endedAt } = times;
+
+ let triggerIcon = icons.QUICK;
+
+ if (trigger === 'manual') {
+ triggerIcon = icons.INTERACTIVE;
+ } else if (trigger === 'scheduled') {
+ triggerIcon = icons.SCHEDULED;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {queuedAt}
+
+
+
+ {startedAt}
+
+
+
+ {endedAt}
+
+
+
+ {formatTimeSpan(duration)}
+
+
+
+ {status === 'queued' && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css b/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css
new file mode 100644
index 000000000..41acb33f8
--- /dev/null
+++ b/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css
@@ -0,0 +1,8 @@
+.commandName {
+ display: inline-block;
+ min-width: 220px;
+}
+
+.userAgent {
+ color: #b0b0b0;
+}
diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css.d.ts b/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css.d.ts
new file mode 100644
index 000000000..fc9081492
--- /dev/null
+++ b/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'commandName': string;
+ 'userAgent': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.tsx b/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.tsx
new file mode 100644
index 000000000..41a307d5f
--- /dev/null
+++ b/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.tsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import { CommandBody } from 'Commands/Command';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import createMultiArtistsSelector from 'Store/Selectors/createMultiArtistsSelector';
+import sortByProp from 'Utilities/Array/sortByProp';
+import translate from 'Utilities/String/translate';
+import styles from './QueuedTaskRowNameCell.css';
+
+function formatTitles(titles: string[]) {
+ if (!titles) {
+ return null;
+ }
+
+ if (titles.length > 11) {
+ return (
+
+ {titles.slice(0, 10).join(', ')}, {titles.length - 10} more
+
+ );
+ }
+
+ return {titles.join(', ')} ;
+}
+
+export interface QueuedTaskRowNameCellProps {
+ commandName: string;
+ body: CommandBody;
+ clientUserAgent?: string;
+}
+
+export default function QueuedTaskRowNameCell(
+ props: QueuedTaskRowNameCellProps
+) {
+ const { commandName, body, clientUserAgent } = props;
+ const movieIds = [...(body.artistIds ?? [])];
+
+ if (body.artistId) {
+ movieIds.push(body.artistId);
+ }
+
+ const artists = useSelector(createMultiArtistsSelector(movieIds));
+ const sortedArtists = artists.sort(sortByProp('sortName'));
+
+ return (
+
+
+ {commandName}
+ {sortedArtists.length ? (
+ - {formatTitles(sortedArtists.map((a) => a.artistName))}
+ ) : null}
+
+
+ {clientUserAgent ? (
+
+ {translate('From')}: {clientUserAgent}
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/src/System/Tasks/Queued/QueuedTasks.tsx b/frontend/src/System/Tasks/Queued/QueuedTasks.tsx
new file mode 100644
index 000000000..e79deed7c
--- /dev/null
+++ b/frontend/src/System/Tasks/Queued/QueuedTasks.tsx
@@ -0,0 +1,74 @@
+import React, { useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import AppState from 'App/State/AppState';
+import FieldSet from 'Components/FieldSet';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import { fetchCommands } from 'Store/Actions/commandActions';
+import translate from 'Utilities/String/translate';
+import QueuedTaskRow from './QueuedTaskRow';
+
+const columns = [
+ {
+ name: 'trigger',
+ label: '',
+ isVisible: true,
+ },
+ {
+ name: 'commandName',
+ label: () => translate('Name'),
+ isVisible: true,
+ },
+ {
+ name: 'queued',
+ label: () => translate('Queued'),
+ isVisible: true,
+ },
+ {
+ name: 'started',
+ label: () => translate('Started'),
+ isVisible: true,
+ },
+ {
+ name: 'ended',
+ label: () => translate('Ended'),
+ isVisible: true,
+ },
+ {
+ name: 'duration',
+ label: () => translate('Duration'),
+ isVisible: true,
+ },
+ {
+ name: 'actions',
+ isVisible: true,
+ },
+];
+
+export default function QueuedTasks() {
+ const dispatch = useDispatch();
+ const { isFetching, isPopulated, items } = useSelector(
+ (state: AppState) => state.commands
+ );
+
+ useEffect(() => {
+ dispatch(fetchCommands());
+ }, [dispatch]);
+
+ return (
+
+ {isFetching && !isPopulated && }
+
+ {isPopulated && (
+
+
+ {items.map((item) => {
+ return ;
+ })}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css
new file mode 100644
index 000000000..a5c84ee3a
--- /dev/null
+++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css
@@ -0,0 +1,19 @@
+.interval {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 150px;
+}
+
+.lastExecution,
+.lastDuration,
+.nextExecution {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 180px;
+}
+
+.actions {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 20px;
+}
diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css.d.ts b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css.d.ts
new file mode 100644
index 000000000..5b9af5313
--- /dev/null
+++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css.d.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'actions': string;
+ 'interval': string;
+ 'lastDuration': string;
+ 'lastExecution': string;
+ 'nextExecution': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js
new file mode 100644
index 000000000..acb8c8d36
--- /dev/null
+++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js
@@ -0,0 +1,203 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableRow from 'Components/Table/TableRow';
+import { icons } from 'Helpers/Props';
+import formatDate from 'Utilities/Date/formatDate';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
+import styles from './ScheduledTaskRow.css';
+
+function getFormattedDates(props) {
+ const {
+ lastExecution,
+ nextExecution,
+ interval,
+ showRelativeDates,
+ shortDateFormat
+ } = props;
+
+ const isDisabled = interval === 0;
+
+ if (showRelativeDates) {
+ return {
+ lastExecutionTime: moment(lastExecution).fromNow(),
+ nextExecutionTime: isDisabled ? '-' : moment(nextExecution).fromNow()
+ };
+ }
+
+ return {
+ lastExecutionTime: formatDate(lastExecution, shortDateFormat),
+ nextExecutionTime: isDisabled ? '-' : formatDate(nextExecution, shortDateFormat)
+ };
+}
+
+class ScheduledTaskRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = getFormattedDates(props);
+
+ this._updateTimeoutId = null;
+ }
+
+ componentDidMount() {
+ this.setUpdateTimer();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ lastExecution,
+ nextExecution
+ } = this.props;
+
+ if (
+ lastExecution !== prevProps.lastExecution ||
+ nextExecution !== prevProps.nextExecution
+ ) {
+ this.setState(getFormattedDates(this.props));
+ }
+ }
+
+ componentWillUnmount() {
+ if (this._updateTimeoutId) {
+ this._updateTimeoutId = clearTimeout(this._updateTimeoutId);
+ }
+ }
+
+ //
+ // Listeners
+
+ setUpdateTimer() {
+ const { interval } = this.props;
+ const timeout = interval < 60 ? 10000 : 60000;
+
+ this._updateTimeoutId = setTimeout(() => {
+ this.setState(getFormattedDates(this.props));
+ this.setUpdateTimer();
+ }, timeout);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ interval,
+ lastExecution,
+ lastStartTime,
+ lastDuration,
+ nextExecution,
+ isQueued,
+ isExecuting,
+ longDateFormat,
+ timeFormat,
+ onExecutePress
+ } = this.props;
+
+ const {
+ lastExecutionTime,
+ nextExecutionTime
+ } = this.state;
+
+ const isDisabled = interval === 0;
+ const executeNow = !isDisabled && moment().isAfter(nextExecution);
+ const hasNextExecutionTime = !isDisabled && !executeNow;
+ const duration = moment.duration(interval, 'minutes').humanize().replace(/an?(?=\s)/, '1');
+ const hasLastStartTime = moment(lastStartTime).isAfter('2010-01-01');
+
+ return (
+
+ {name}
+
+ {isDisabled ? 'disabled' : duration}
+
+
+
+ {lastExecutionTime}
+
+
+ {
+ !hasLastStartTime &&
+ -
+ }
+
+ {
+ hasLastStartTime &&
+
+ {formatTimeSpan(lastDuration)}
+
+ }
+
+ {
+ isDisabled &&
+ -
+ }
+
+ {
+ executeNow && isQueued &&
+ queued
+ }
+
+ {
+ executeNow && !isQueued &&
+ now
+ }
+
+ {
+ hasNextExecutionTime &&
+
+ {nextExecutionTime}
+
+ }
+
+
+
+
+
+ );
+ }
+}
+
+ScheduledTaskRow.propTypes = {
+ name: PropTypes.string.isRequired,
+ interval: PropTypes.number.isRequired,
+ lastExecution: PropTypes.string.isRequired,
+ lastStartTime: PropTypes.string.isRequired,
+ lastDuration: PropTypes.string.isRequired,
+ nextExecution: PropTypes.string.isRequired,
+ isQueued: PropTypes.bool.isRequired,
+ isExecuting: PropTypes.bool.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onExecutePress: PropTypes.func.isRequired
+};
+
+export default ScheduledTaskRow;
diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js
new file mode 100644
index 000000000..dae790d68
--- /dev/null
+++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js
@@ -0,0 +1,92 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { fetchTask } from 'Store/Actions/systemActions';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import { findCommand, isCommandExecuting } from 'Utilities/Command';
+import ScheduledTaskRow from './ScheduledTaskRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { taskName }) => taskName,
+ createCommandsSelector(),
+ createUISettingsSelector(),
+ (taskName, commands, uiSettings) => {
+ const command = findCommand(commands, { name: taskName });
+
+ return {
+ isQueued: !!(command && command.state === 'queued'),
+ isExecuting: isCommandExecuting(command),
+ showRelativeDates: uiSettings.showRelativeDates,
+ shortDateFormat: uiSettings.shortDateFormat,
+ longDateFormat: uiSettings.longDateFormat,
+ timeFormat: uiSettings.timeFormat
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ const taskName = props.taskName;
+
+ return {
+ dispatchFetchTask() {
+ dispatch(fetchTask({
+ id: props.id
+ }));
+ },
+
+ onExecutePress() {
+ dispatch(executeCommand({
+ name: taskName
+ }));
+ }
+ };
+}
+
+class ScheduledTaskRowConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps) {
+ const {
+ isExecuting,
+ dispatchFetchTask
+ } = this.props;
+
+ if (!isExecuting && prevProps.isExecuting) {
+ // Give the host a moment to update after the command completes
+ setTimeout(() => {
+ dispatchFetchTask();
+ }, 1000);
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchFetchTask,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+ScheduledTaskRowConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ isExecuting: PropTypes.bool.isRequired,
+ dispatchFetchTask: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(ScheduledTaskRowConnector);
diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js b/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js
new file mode 100644
index 000000000..bec151613
--- /dev/null
+++ b/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js
@@ -0,0 +1,85 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import FieldSet from 'Components/FieldSet';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import translate from 'Utilities/String/translate';
+import ScheduledTaskRowConnector from './ScheduledTaskRowConnector';
+
+const columns = [
+ {
+ name: 'name',
+ label: () => translate('Name'),
+ isVisible: true
+ },
+ {
+ name: 'interval',
+ label: () => translate('Interval'),
+ isVisible: true
+ },
+ {
+ name: 'lastExecution',
+ label: () => translate('LastExecution'),
+ isVisible: true
+ },
+ {
+ name: 'lastDuration',
+ label: () => translate('LastDuration'),
+ isVisible: true
+ },
+ {
+ name: 'nextExecution',
+ label: () => translate('NextExecution'),
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ isVisible: true
+ }
+];
+
+function ScheduledTasks(props) {
+ const {
+ isFetching,
+ isPopulated,
+ items
+ } = props;
+
+ return (
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ isPopulated &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+ );
+}
+
+ScheduledTasks.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ items: PropTypes.array.isRequired
+};
+
+export default ScheduledTasks;
diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js b/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js
new file mode 100644
index 000000000..8f418d3bb
--- /dev/null
+++ b/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js
@@ -0,0 +1,46 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchTasks } from 'Store/Actions/systemActions';
+import ScheduledTasks from './ScheduledTasks';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.tasks,
+ (tasks) => {
+ return tasks;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchTasks: fetchTasks
+};
+
+class ScheduledTasksConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchTasks();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ScheduledTasksConnector.propTypes = {
+ dispatchFetchTasks: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ScheduledTasksConnector);
diff --git a/frontend/src/System/Tasks/Tasks.js b/frontend/src/System/Tasks/Tasks.js
new file mode 100644
index 000000000..03a3b6ce4
--- /dev/null
+++ b/frontend/src/System/Tasks/Tasks.js
@@ -0,0 +1,19 @@
+import React from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import translate from 'Utilities/String/translate';
+import QueuedTasks from './Queued/QueuedTasks';
+import ScheduledTasksConnector from './Scheduled/ScheduledTasksConnector';
+
+function Tasks() {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export default Tasks;
diff --git a/frontend/src/System/Updates/UpdateChanges.css b/frontend/src/System/Updates/UpdateChanges.css
new file mode 100644
index 000000000..d21897373
--- /dev/null
+++ b/frontend/src/System/Updates/UpdateChanges.css
@@ -0,0 +1,4 @@
+.title {
+ margin-top: 10px;
+ font-size: 16px;
+}
diff --git a/frontend/src/System/Updates/UpdateChanges.css.d.ts b/frontend/src/System/Updates/UpdateChanges.css.d.ts
new file mode 100644
index 000000000..86bceec06
--- /dev/null
+++ b/frontend/src/System/Updates/UpdateChanges.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'title': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/System/Updates/UpdateChanges.tsx b/frontend/src/System/Updates/UpdateChanges.tsx
new file mode 100644
index 000000000..3e5ba1c9b
--- /dev/null
+++ b/frontend/src/System/Updates/UpdateChanges.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
+import styles from './UpdateChanges.css';
+
+interface UpdateChangesProps {
+ title: string;
+ changes: string[];
+}
+
+function UpdateChanges(props: UpdateChangesProps) {
+ const { title, changes } = props;
+
+ if (changes.length === 0) {
+ return null;
+ }
+
+ const uniqueChanges = [...new Set(changes)];
+
+ return (
+
+
{title}
+
+ {uniqueChanges.map((change, index) => {
+ const checkChange = change.replace(
+ /#\d{4,5}\b/g,
+ (match) =>
+ `[${match}](https://github.com/Lidarr/Lidarr/issues/${match.substring(
+ 1
+ )})`
+ );
+
+ return (
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+export default UpdateChanges;
diff --git a/frontend/src/System/Updates/Updates.css b/frontend/src/System/Updates/Updates.css
new file mode 100644
index 000000000..6ed588890
--- /dev/null
+++ b/frontend/src/System/Updates/Updates.css
@@ -0,0 +1,53 @@
+.messageContainer {
+ display: flex;
+ margin-bottom: 20px;
+}
+
+.upToDateIcon {
+ color: #37bc9b;
+ font-size: 30px;
+}
+
+.message {
+ padding-left: 5px;
+ font-size: 18px;
+ line-height: 30px;
+}
+
+.loading {
+ composes: loading from '~Components/Loading/LoadingIndicator.css';
+
+ margin-top: 5px;
+ margin-left: auto;
+}
+
+.update {
+ margin-top: 20px;
+}
+
+.info {
+ display: flex;
+ align-items: center;
+ margin-bottom: 10px;
+ padding-bottom: 5px;
+ border-bottom: 1px solid #e5e5e5;
+}
+
+.version {
+ font-size: 21px;
+}
+
+.space {
+ padding: 0 5px;
+}
+
+.date {
+ font-size: 16px;
+}
+
+.label {
+ composes: label from '~Components/Label.css';
+
+ margin-left: 10px;
+ font-size: 14px;
+}
diff --git a/frontend/src/System/Updates/Updates.css.d.ts b/frontend/src/System/Updates/Updates.css.d.ts
new file mode 100644
index 000000000..ed1f5434f
--- /dev/null
+++ b/frontend/src/System/Updates/Updates.css.d.ts
@@ -0,0 +1,16 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'date': string;
+ 'info': string;
+ 'label': string;
+ 'loading': string;
+ 'message': string;
+ 'messageContainer': string;
+ 'space': string;
+ 'upToDateIcon': string;
+ 'update': string;
+ 'version': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/System/Updates/Updates.tsx b/frontend/src/System/Updates/Updates.tsx
new file mode 100644
index 000000000..300ab1f99
--- /dev/null
+++ b/frontend/src/System/Updates/Updates.tsx
@@ -0,0 +1,303 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import * as commandNames from 'Commands/commandNames';
+import Alert from 'Components/Alert';
+import Icon from 'Components/Icon';
+import Label from 'Components/Label';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import { icons, kinds } from 'Helpers/Props';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { fetchGeneralSettings } from 'Store/Actions/settingsActions';
+import { fetchUpdates } from 'Store/Actions/systemActions';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import { UpdateMechanism } from 'typings/Settings/General';
+import formatDate from 'Utilities/Date/formatDate';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import translate from 'Utilities/String/translate';
+import UpdateChanges from './UpdateChanges';
+import styles from './Updates.css';
+
+const VERSION_REGEX = /\d+\.\d+\.\d+\.\d+/i;
+
+function createUpdatesSelector() {
+ return createSelector(
+ (state: AppState) => state.system.updates,
+ (state: AppState) => state.settings.general,
+ (updates, generalSettings) => {
+ const { error: updatesError, items } = updates;
+
+ const isFetching = updates.isFetching || generalSettings.isFetching;
+ const isPopulated = updates.isPopulated && generalSettings.isPopulated;
+
+ return {
+ isFetching,
+ isPopulated,
+ updatesError,
+ generalSettingsError: generalSettings.error,
+ items,
+ updateMechanism: generalSettings.item.updateMechanism,
+ };
+ }
+ );
+}
+
+function Updates() {
+ const currentVersion = useSelector((state: AppState) => state.app.version);
+ const { packageUpdateMechanismMessage } = useSelector(
+ createSystemStatusSelector()
+ );
+ const { shortDateFormat, longDateFormat, timeFormat } = useSelector(
+ createUISettingsSelector()
+ );
+ const isInstallingUpdate = useSelector(
+ createCommandExecutingSelector(commandNames.APPLICATION_UPDATE)
+ );
+
+ const {
+ isFetching,
+ isPopulated,
+ updatesError,
+ generalSettingsError,
+ items,
+ updateMechanism,
+ } = useSelector(createUpdatesSelector());
+
+ const dispatch = useDispatch();
+ const [isMajorUpdateModalOpen, setIsMajorUpdateModalOpen] = useState(false);
+ const hasError = !!(updatesError || generalSettingsError);
+ const hasUpdates = isPopulated && !hasError && items.length > 0;
+ const noUpdates = isPopulated && !hasError && !items.length;
+
+ const externalUpdaterPrefix = translate('UpdateAppDirectlyLoadError');
+ const externalUpdaterMessages: Partial> = {
+ external: translate('ExternalUpdater'),
+ apt: translate('AptUpdater'),
+ docker: translate('DockerUpdater'),
+ };
+
+ const { isMajorUpdate, hasUpdateToInstall } = useMemo(() => {
+ const majorVersion = parseInt(
+ currentVersion.match(VERSION_REGEX)?.[0] ?? '0'
+ );
+
+ const latestVersion = items[0]?.version;
+ const latestMajorVersion = parseInt(
+ latestVersion?.match(VERSION_REGEX)?.[0] ?? '0'
+ );
+
+ return {
+ isMajorUpdate: latestMajorVersion > majorVersion,
+ hasUpdateToInstall: items.some(
+ (update) => update.installable && update.latest
+ ),
+ };
+ }, [currentVersion, items]);
+
+ const noUpdateToInstall = hasUpdates && !hasUpdateToInstall;
+
+ const handleInstallLatestPress = useCallback(() => {
+ if (isMajorUpdate) {
+ setIsMajorUpdateModalOpen(true);
+ } else {
+ dispatch(executeCommand({ name: commandNames.APPLICATION_UPDATE }));
+ }
+ }, [isMajorUpdate, setIsMajorUpdateModalOpen, dispatch]);
+
+ const handleInstallLatestMajorVersionPress = useCallback(() => {
+ setIsMajorUpdateModalOpen(false);
+
+ dispatch(
+ executeCommand({
+ name: commandNames.APPLICATION_UPDATE,
+ installMajorUpdate: true,
+ })
+ );
+ }, [setIsMajorUpdateModalOpen, dispatch]);
+
+ const handleCancelMajorVersionPress = useCallback(() => {
+ setIsMajorUpdateModalOpen(false);
+ }, [setIsMajorUpdateModalOpen]);
+
+ useEffect(() => {
+ dispatch(fetchUpdates());
+ dispatch(fetchGeneralSettings());
+ }, [dispatch]);
+
+ return (
+
+
+ {isPopulated || hasError ? null : }
+
+ {noUpdates ? (
+ {translate('NoUpdatesAreAvailable')}
+ ) : null}
+
+ {hasUpdateToInstall ? (
+
+ {updateMechanism === 'builtIn' || updateMechanism === 'script' ? (
+
+ {translate('InstallLatest')}
+
+ ) : (
+ <>
+
+
+
+ {externalUpdaterPrefix}{' '}
+
+
+ >
+ )}
+
+ {isFetching ? (
+
+ ) : null}
+
+ ) : null}
+
+ {noUpdateToInstall && (
+
+
+
{translate('OnLatestVersion')}
+
+ {isFetching && (
+
+ )}
+
+ )}
+
+ {hasUpdates && (
+
+ {items.map((update) => {
+ return (
+
+
+
{update.version}
+
—
+
+ {formatDate(update.releaseDate, shortDateFormat)}
+
+
+ {update.branch === 'master' ? null : (
+
{update.branch}
+ )}
+
+ {update.version === currentVersion ? (
+
+ {translate('CurrentlyInstalled')}
+
+ ) : null}
+
+ {update.version !== currentVersion && update.installedOn ? (
+
+ {translate('PreviouslyInstalled')}
+
+ ) : null}
+
+
+ {update.changes ? (
+
+
+
+
+
+ ) : (
+
{translate('MaintenanceRelease')}
+ )}
+
+ );
+ })}
+
+ )}
+
+ {updatesError ? (
+
+ {translate('FailedToFetchUpdates')}
+
+ ) : null}
+
+ {generalSettingsError ? (
+
+ {translate('FailedToFetchSettings')}
+
+ ) : null}
+
+
+ {translate('InstallMajorVersionUpdateMessage')}
+
+
+
+
+ }
+ confirmLabel={translate('Install')}
+ onConfirm={handleInstallLatestMajorVersionPress}
+ onCancel={handleCancelMajorVersionPress}
+ />
+
+
+ );
+}
+
+export default Updates;
diff --git a/frontend/src/Track/Track.ts b/frontend/src/Track/Track.ts
new file mode 100644
index 000000000..7c080290a
--- /dev/null
+++ b/frontend/src/Track/Track.ts
@@ -0,0 +1,19 @@
+import ModelBase from 'App/ModelBase';
+
+interface Track extends ModelBase {
+ artistId: number;
+ foreignTrackId: string;
+ foreignRecordingId: string;
+ trackFileId: number;
+ albumId: number;
+ explicit: boolean;
+ absoluteTrackNumber: number;
+ trackNumber: string;
+ title: string;
+ duration: number;
+ trackFile?: object;
+ mediumNumber: number;
+ hasFile: boolean;
+}
+
+export default Track;
diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModal.js b/frontend/src/TrackFile/Editor/TrackFileEditorModal.js
new file mode 100644
index 000000000..7f52aca05
--- /dev/null
+++ b/frontend/src/TrackFile/Editor/TrackFileEditorModal.js
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import TrackFileEditorModalContentConnector from './TrackFileEditorModalContentConnector';
+
+function TrackFileEditorModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {
+ isOpen &&
+
+ }
+
+ );
+}
+
+TrackFileEditorModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default TrackFileEditorModal;
diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.css b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.css
new file mode 100644
index 000000000..49e946826
--- /dev/null
+++ b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.css
@@ -0,0 +1,8 @@
+.actions {
+ display: flex;
+ margin-right: auto;
+}
+
+.selectInput {
+ margin-left: 10px;
+}
diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.css.d.ts b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.css.d.ts
new file mode 100644
index 000000000..3c384a307
--- /dev/null
+++ b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'actions': string;
+ 'selectInput': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js
new file mode 100644
index 000000000..0e387f39f
--- /dev/null
+++ b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js
@@ -0,0 +1,263 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import SelectInput from 'Components/Form/SelectInput';
+import Button from 'Components/Link/Button';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import { kinds } from 'Helpers/Props';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import translate from 'Utilities/String/translate';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import TrackFileEditorRow from './TrackFileEditorRow';
+import styles from './TrackFileEditorModalContent.css';
+
+const columns = [
+ {
+ name: 'trackNumber',
+ label: () => translate('Track'),
+ isVisible: true
+ },
+ {
+ name: 'path',
+ label: () => translate('Path'),
+ isVisible: true
+ },
+ {
+ name: 'quality',
+ label: () => translate('Quality'),
+ isVisible: true
+ }
+];
+
+class TrackFileEditorModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ isConfirmDeleteModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ this.setState((state) => {
+ return removeOldSelectedState(state, prevProps.items);
+ });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ const selectedIds = getSelectedIds(this.state.selectedState);
+
+ return selectedIds.reduce((acc, id) => {
+ const matchingItem = this.props.items.find((item) => item.id === id);
+
+ if (matchingItem && !acc.includes(matchingItem.trackFileId)) {
+ acc.push(matchingItem.trackFileId);
+ }
+
+ return acc;
+ }, []);
+ };
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ };
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ };
+
+ onDeletePress = () => {
+ this.setState({ isConfirmDeleteModalOpen: true });
+ };
+
+ onConfirmDelete = () => {
+ this.setState({ isConfirmDeleteModalOpen: false });
+ this.props.onDeletePress(this.getSelectedIds());
+ };
+
+ onConfirmDeleteModalClose = () => {
+ this.setState({ isConfirmDeleteModalOpen: false });
+ };
+
+ onQualityChange = ({ value }) => {
+ const selectedIds = this.getSelectedIds();
+
+ if (!selectedIds.length) {
+ return;
+ }
+
+ this.props.onQualityChange(selectedIds, parseInt(value));
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ isDeleting,
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ qualities,
+ onModalClose
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ isConfirmDeleteModalOpen
+ } = this.state;
+
+ const qualityOptions = _.reduceRight(qualities, (acc, quality) => {
+ acc.push({
+ key: quality.id,
+ value: quality.name
+ });
+
+ return acc;
+ }, [{ key: 'selectQuality', value: translate('SelectQuality'), isDisabled: true }]);
+
+ const hasSelectedFiles = this.getSelectedIds().length > 0;
+
+ return (
+
+
+ Manage Tracks
+
+
+
+ {
+ isFetching && !isPopulated ?
+ :
+ null
+ }
+
+ {
+ !isFetching && error ?
+ {error}
:
+ null
+ }
+
+ {
+ isPopulated && !items.length ?
+
+ No track files to manage.
+
:
+ null
+ }
+
+ {
+ isPopulated && items.length ?
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
:
+ null
+ }
+
+
+
+
+
+
+ {translate('Close')}
+
+
+
+
+
+ );
+ }
+}
+
+TrackFileEditorModalContent.propTypes = {
+ isDeleting: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ qualities: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onDeletePress: PropTypes.func.isRequired,
+ onQualityChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default TrackFileEditorModalContent;
diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js b/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js
new file mode 100644
index 000000000..71bb1a4f4
--- /dev/null
+++ b/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js
@@ -0,0 +1,174 @@
+/* eslint max-params: 0 */
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
+import { clearTracks, fetchTracks } from 'Store/Actions/trackActions';
+import { deleteTrackFiles, updateTrackFiles } from 'Store/Actions/trackFileActions';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import getQualities from 'Utilities/Quality/getQualities';
+import TrackFileEditorModalContent from './TrackFileEditorModalContent';
+
+function createSchemaSelector() {
+ return createSelector(
+ (state) => state.settings.qualityProfiles,
+ (qualityProfiles) => {
+ const qualities = getQualities(qualityProfiles.schema.items);
+
+ let error = null;
+
+ if (qualityProfiles.schemaError) {
+ error = 'Unable to load qualities';
+ }
+
+ return {
+ isFetching: qualityProfiles.isSchemaFetching,
+ isPopulated: qualityProfiles.isSchemaPopulated,
+ error,
+ qualities
+ };
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { albumId }) => albumId,
+ (state) => state.tracks,
+ (state) => state.trackFiles,
+ createSchemaSelector(),
+ createArtistSelector(),
+ (
+ albumId,
+ tracks,
+ trackFiles,
+ schema,
+ artist
+ ) => {
+ const filtered = _.filter(tracks.items, (track) => {
+ if (albumId >= 0 && track.albumId !== albumId) {
+ return false;
+ }
+
+ if (!track.trackFileId) {
+ return false;
+ }
+
+ return _.some(trackFiles.items, { id: track.trackFileId });
+ });
+
+ const sorted = _.orderBy(filtered, ['albumId', 'mediumNumber', 'absoluteTrackNumber'], ['desc', 'asc', 'asc']);
+
+ const items = _.map(sorted, (track) => {
+ const trackFile = _.find(trackFiles.items, { id: track.trackFileId });
+
+ return {
+ path: trackFile.path,
+ quality: trackFile.quality,
+ qualityCutoffNotMet: trackFile.qualityCutoffNotMet,
+ ...track
+ };
+ });
+
+ return {
+ ...schema,
+ items,
+ artistType: artist.artistType,
+ isDeleting: trackFiles.isDeleting,
+ isSaving: trackFiles.isSaving
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchClearTracks() {
+ dispatch(clearTracks());
+ },
+
+ dispatchFetchTracks(updateProps) {
+ dispatch(fetchTracks(updateProps));
+ },
+
+ dispatchFetchQualityProfileSchema(name, path) {
+ dispatch(fetchQualityProfileSchema());
+ },
+
+ dispatchUpdateTrackFiles(updateProps) {
+ dispatch(updateTrackFiles(updateProps));
+ },
+
+ onDeletePress(trackFileIds) {
+ dispatch(deleteTrackFiles({ trackFileIds }));
+ }
+ };
+}
+
+class TrackFileEditorModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const artistId = this.props.artistId;
+ const albumId = this.props.albumId;
+
+ this.props.dispatchFetchTracks({ artistId, albumId });
+
+ this.props.dispatchFetchQualityProfileSchema();
+ }
+
+ componentWillUnmount() {
+ this.props.dispatchClearTracks();
+ }
+
+ //
+ // Listeners
+
+ onQualityChange = (trackFileIds, qualityId) => {
+ const quality = {
+ quality: _.find(this.props.qualities, { id: qualityId }),
+ revision: {
+ version: 1,
+ real: 0
+ }
+ };
+
+ this.props.dispatchUpdateTrackFiles({ trackFileIds, quality });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchFetchQualityProfileSchema,
+ dispatchUpdateTrackFiles,
+ dispatchFetchTracks,
+ dispatchClearTracks,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+TrackFileEditorModalContentConnector.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ albumId: PropTypes.number,
+ qualities: PropTypes.arrayOf(PropTypes.object).isRequired,
+ dispatchFetchTracks: PropTypes.func.isRequired,
+ dispatchClearTracks: PropTypes.func.isRequired,
+ dispatchFetchQualityProfileSchema: PropTypes.func.isRequired,
+ dispatchUpdateTrackFiles: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(TrackFileEditorModalContentConnector);
diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorRow.js b/frontend/src/TrackFile/Editor/TrackFileEditorRow.js
new file mode 100644
index 000000000..a2e616de8
--- /dev/null
+++ b/frontend/src/TrackFile/Editor/TrackFileEditorRow.js
@@ -0,0 +1,56 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import TrackQuality from 'Album/TrackQuality';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import TableRow from 'Components/Table/TableRow';
+import padNumber from 'Utilities/Number/padNumber';
+
+function TrackFileEditorRow(props) {
+ const {
+ id,
+ trackNumber,
+ path,
+ quality,
+ qualityCutoffNotMet,
+ isSelected,
+ onSelectedChange
+ } = props;
+
+ return (
+
+
+
+
+ {padNumber(trackNumber, 2)}
+
+
+
+ {path}
+
+
+
+
+
+
+ );
+}
+
+TrackFileEditorRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ trackNumber: PropTypes.string.isRequired,
+ path: PropTypes.string.isRequired,
+ quality: PropTypes.object.isRequired,
+ qualityCutoffNotMet: PropTypes.bool.isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+export default TrackFileEditorRow;
diff --git a/frontend/src/TrackFile/ExpandingFileDetails.css b/frontend/src/TrackFile/ExpandingFileDetails.css
new file mode 100644
index 000000000..23af8caf7
--- /dev/null
+++ b/frontend/src/TrackFile/ExpandingFileDetails.css
@@ -0,0 +1,61 @@
+.fileDetails {
+ margin-bottom: 20px;
+ border: 1px solid var(--borderColor);
+ border-radius: 4px;
+ background-color: var(--cardBackgroundColor);
+
+ &:last-of-type {
+ margin-bottom: 0;
+ }
+}
+
+.filename {
+ flex-grow: 1;
+ margin-right: 10px;
+ margin-left: 10px;
+ font-size: 14px;
+ font-family: $monoSpaceFontFamily;
+}
+
+.header {
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ font-size: 18px;
+}
+
+.expandButton {
+ position: relative;
+ width: 60px;
+ height: 60px;
+}
+
+.actionButton {
+ composes: button from '~Components/Link/IconButton.css';
+
+ width: 30px;
+}
+
+.expandButtonIcon {
+ composes: actionButton;
+
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin-top: -12px;
+ margin-left: -15px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .medium {
+ border-right: 0;
+ border-left: 0;
+ border-radius: 0;
+ }
+
+ .expandButtonIcon {
+ position: static;
+ margin: 0;
+ }
+}
diff --git a/frontend/src/TrackFile/ExpandingFileDetails.css.d.ts b/frontend/src/TrackFile/ExpandingFileDetails.css.d.ts
new file mode 100644
index 000000000..55c305cf3
--- /dev/null
+++ b/frontend/src/TrackFile/ExpandingFileDetails.css.d.ts
@@ -0,0 +1,13 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'actionButton': string;
+ 'expandButton': string;
+ 'expandButtonIcon': string;
+ 'fileDetails': string;
+ 'filename': string;
+ 'header': string;
+ 'medium': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/TrackFile/ExpandingFileDetails.js b/frontend/src/TrackFile/ExpandingFileDetails.js
new file mode 100644
index 000000000..15e5d3625
--- /dev/null
+++ b/frontend/src/TrackFile/ExpandingFileDetails.js
@@ -0,0 +1,84 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Icon from 'Components/Icon';
+import { icons } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import FileDetails from './FileDetails';
+import styles from './ExpandingFileDetails.css';
+
+class ExpandingFileDetails extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isExpanded: props.isExpanded
+ };
+ }
+
+ //
+ // Listeners
+
+ onExpandPress = () => {
+ const {
+ isExpanded
+ } = this.state;
+ this.setState({ isExpanded: !isExpanded });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ filename,
+ audioTags,
+ rejections
+ } = this.props;
+
+ const {
+ isExpanded
+ } = this.state;
+
+ return (
+
+
+
+ {filename}
+
+
+
+
+
+
+
+ {
+ isExpanded &&
+
+ }
+
+ );
+ }
+}
+
+ExpandingFileDetails.propTypes = {
+ audioTags: PropTypes.object.isRequired,
+ filename: PropTypes.string.isRequired,
+ rejections: PropTypes.arrayOf(PropTypes.object),
+ isExpanded: PropTypes.bool
+};
+
+export default ExpandingFileDetails;
diff --git a/frontend/src/TrackFile/FileDetails.css b/frontend/src/TrackFile/FileDetails.css
new file mode 100644
index 000000000..f157f7b7b
--- /dev/null
+++ b/frontend/src/TrackFile/FileDetails.css
@@ -0,0 +1,12 @@
+.audioTags {
+ padding-top: 15px;
+ padding-bottom: 15px;
+ word-break: break-word;
+ /* border-top: 1px solid $borderColor; */
+}
+
+.filename {
+ composes: description from '~Components/DescriptionList/DescriptionListItemDescription.css';
+
+ font-family: $monoSpaceFontFamily;
+}
diff --git a/frontend/src/TrackFile/FileDetails.css.d.ts b/frontend/src/TrackFile/FileDetails.css.d.ts
new file mode 100644
index 000000000..b327cff06
--- /dev/null
+++ b/frontend/src/TrackFile/FileDetails.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'audioTags': string;
+ 'filename': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/TrackFile/FileDetails.js b/frontend/src/TrackFile/FileDetails.js
new file mode 100644
index 000000000..ae2520cd0
--- /dev/null
+++ b/frontend/src/TrackFile/FileDetails.js
@@ -0,0 +1,207 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Fragment } from 'react';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
+import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
+import Link from 'Components/Link/Link';
+import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
+import translate from 'Utilities/String/translate';
+import styles from './FileDetails.css';
+
+function renderRejections(rejections) {
+ return (
+
+
+ Rejections
+
+ {
+ _.map(rejections, (item, key) => {
+ return (
+
+ {item.reason}
+
+ );
+ })
+ }
+
+ );
+}
+
+function FileDetails(props) {
+
+ const {
+ filename,
+ audioTags,
+ rejections
+ } = props;
+
+ return (
+
+
+
+ {
+ filename &&
+
+ }
+ {
+ audioTags.title !== undefined &&
+
+ }
+ {
+ audioTags.trackNumbers[0] > 0 &&
+
+ }
+ {
+ audioTags.discNumber > 0 &&
+
+ }
+ {
+ audioTags.discCount > 0 &&
+
+ }
+ {
+ audioTags.albumTitle !== undefined &&
+
+ }
+ {
+ audioTags.artistTitle !== undefined &&
+
+ }
+ {
+ audioTags.country !== undefined &&
+
+ }
+ {
+ audioTags.year > 0 &&
+
+ }
+ {
+ audioTags.label !== undefined &&
+
+ }
+ {
+ audioTags.catalogNumber !== undefined &&
+
+ }
+ {
+ audioTags.disambiguation !== undefined &&
+
+ }
+ {
+ audioTags.duration !== undefined &&
+
+ }
+ {
+ audioTags.artistMBId !== undefined &&
+
+
+
+ }
+ {
+ audioTags.albumMBId !== undefined &&
+
+
+
+ }
+ {
+ audioTags.releaseMBId !== undefined &&
+
+
+
+ }
+ {
+ audioTags.recordingMBId !== undefined &&
+
+
+
+ }
+ {
+ audioTags.trackMBId !== undefined &&
+
+
+
+ }
+ {
+ !!rejections && rejections.length > 0 &&
+ renderRejections(rejections)
+ }
+
+
+
+ );
+}
+
+FileDetails.propTypes = {
+ filename: PropTypes.string,
+ audioTags: PropTypes.object.isRequired,
+ rejections: PropTypes.arrayOf(PropTypes.object)
+};
+
+export default FileDetails;
diff --git a/frontend/src/TrackFile/FileDetailsConnector.js b/frontend/src/TrackFile/FileDetailsConnector.js
new file mode 100644
index 000000000..28f8766d0
--- /dev/null
+++ b/frontend/src/TrackFile/FileDetailsConnector.js
@@ -0,0 +1,77 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import { fetchTrackFiles } from 'Store/Actions/trackFileActions';
+import getErrorMessage from 'Utilities/Object/getErrorMessage';
+import FileDetails from './FileDetails';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.trackFiles,
+ (trackFiles) => {
+ return {
+ ...trackFiles
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchTrackFiles
+};
+
+class FileDetailsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchTrackFiles({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ id,
+ isFetching,
+ error
+ } = this.props;
+
+ const item = _.find(items, { id });
+ const errorMessage = getErrorMessage(error, 'Unable to load manual import items');
+
+ if (isFetching || !item.audioTags) {
+ return (
+
+ );
+ } else if (error) {
+ return (
+ {errorMessage}
+ );
+ }
+
+ return (
+
+ );
+
+ }
+}
+
+FileDetailsConnector.propTypes = {
+ fetchTrackFiles: PropTypes.func.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ id: PropTypes.number.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(FileDetailsConnector);
diff --git a/frontend/src/TrackFile/FileDetailsModal.js b/frontend/src/TrackFile/FileDetailsModal.js
new file mode 100644
index 000000000..d9ebe2f3b
--- /dev/null
+++ b/frontend/src/TrackFile/FileDetailsModal.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Button from 'Components/Link/Button';
+import Modal from 'Components/Modal/Modal';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import FileDetailsConnector from './FileDetailsConnector';
+
+function FileDetailsModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ id
+ } = props;
+
+ return (
+
+
+
+ Details
+
+
+
+
+
+
+
+
+ Close
+
+
+
+
+ );
+}
+
+FileDetailsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ id: PropTypes.number.isRequired
+};
+
+export default FileDetailsModal;
diff --git a/frontend/src/TrackFile/MediaInfo.js b/frontend/src/TrackFile/MediaInfo.js
new file mode 100644
index 000000000..3f50fb70e
--- /dev/null
+++ b/frontend/src/TrackFile/MediaInfo.js
@@ -0,0 +1,78 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import * as mediaInfoTypes from './mediaInfoTypes';
+
+function MediaInfo(props) {
+ const {
+ type,
+ audioChannels,
+ audioCodec,
+ audioBitRate,
+ audioBits,
+ audioSampleRate
+ } = props;
+
+ if (type === mediaInfoTypes.AUDIO) {
+ return (
+
+ {
+ !!audioCodec &&
+ audioCodec
+ }
+
+ {
+ !!audioCodec && !!audioChannels &&
+ ' - '
+ }
+
+ {
+ !!audioChannels &&
+ audioChannels.toFixed(1)
+ }
+
+ {
+ ((!!audioCodec && !!audioBitRate) || (!!audioChannels && !!audioBitRate)) &&
+ ' - '
+ }
+
+ {
+ !!audioBitRate &&
+ audioBitRate
+ }
+
+ {
+ ((!!audioCodec && !!audioSampleRate) || (!!audioChannels && !!audioSampleRate) || (!!audioBitRate && !!audioSampleRate)) &&
+ ' - '
+ }
+
+ {
+ !!audioSampleRate &&
+ audioSampleRate
+ }
+
+ {
+ ((!!audioCodec && !!audioBits) || (!!audioChannels && !!audioBits) || (!!audioBitRate && !!audioBits) || (!!audioSampleRate && !!audioBits)) &&
+ ' - '
+ }
+
+ {
+ !!audioBits &&
+ audioBits
+ }
+
+ );
+ }
+
+ return null;
+}
+
+MediaInfo.propTypes = {
+ type: PropTypes.string.isRequired,
+ audioChannels: PropTypes.number,
+ audioCodec: PropTypes.string,
+ audioBitRate: PropTypes.string,
+ audioBits: PropTypes.string,
+ audioSampleRate: PropTypes.string
+};
+
+export default MediaInfo;
diff --git a/frontend/src/TrackFile/MediaInfoConnector.js b/frontend/src/TrackFile/MediaInfoConnector.js
new file mode 100644
index 000000000..5f3a1386b
--- /dev/null
+++ b/frontend/src/TrackFile/MediaInfoConnector.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector';
+import MediaInfo from './MediaInfo';
+
+function createMapStateToProps() {
+ return createSelector(
+ createTrackFileSelector(),
+ (trackFile) => {
+ if (trackFile) {
+ return {
+ ...trackFile.mediaInfo
+ };
+ }
+
+ return {};
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(MediaInfo);
diff --git a/frontend/src/TrackFile/TrackFile.ts b/frontend/src/TrackFile/TrackFile.ts
new file mode 100644
index 000000000..ef4dc65f3
--- /dev/null
+++ b/frontend/src/TrackFile/TrackFile.ts
@@ -0,0 +1,19 @@
+import ModelBase from 'App/ModelBase';
+import { QualityModel } from 'Quality/Quality';
+import CustomFormat from 'typings/CustomFormat';
+import MediaInfo from 'typings/MediaInfo';
+
+export interface TrackFile extends ModelBase {
+ artistId: number;
+ albumId: number;
+ path: string;
+ size: number;
+ dateAdded: string;
+ sceneName: string;
+ releaseGroup: string;
+ quality: QualityModel;
+ customFormats: CustomFormat[];
+ indexerFlags: number;
+ mediaInfo: MediaInfo;
+ qualityCutoffNotMet: boolean;
+}
diff --git a/frontend/src/TrackFile/mediaInfoTypes.js b/frontend/src/TrackFile/mediaInfoTypes.js
new file mode 100644
index 000000000..5e5a78e64
--- /dev/null
+++ b/frontend/src/TrackFile/mediaInfoTypes.js
@@ -0,0 +1,2 @@
+export const AUDIO = 'audio';
+export const VIDEO = 'video';
diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTable.js b/frontend/src/UnmappedFiles/UnmappedFilesTable.js
new file mode 100644
index 000000000..a326f91e0
--- /dev/null
+++ b/frontend/src/UnmappedFiles/UnmappedFilesTable.js
@@ -0,0 +1,290 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Alert from 'Components/Alert';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
+import VirtualTable from 'Components/Table/VirtualTable';
+import VirtualTableRow from 'Components/Table/VirtualTableRow';
+import { align, icons, kinds, sortDirections } from 'Helpers/Props';
+import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder';
+import translate from 'Utilities/String/translate';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import UnmappedFilesTableHeader from './UnmappedFilesTableHeader';
+import UnmappedFilesTableRow from './UnmappedFilesTableRow';
+
+class UnmappedFilesTable extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.scrollerRef = React.createRef();
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {}
+ };
+ }
+
+ componentDidMount() {
+ this.setSelectedState();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ sortKey,
+ sortDirection,
+ isDeleting,
+ deleteError
+ } = this.props;
+
+ if (sortKey !== prevProps.sortKey ||
+ sortDirection !== prevProps.sortDirection ||
+ hasDifferentItemsOrOrder(prevProps.items, items)
+ ) {
+ this.setSelectedState();
+ }
+
+ const hasFinishedDeleting = prevProps.isDeleting &&
+ !isDeleting &&
+ !deleteError;
+
+ if (hasFinishedDeleting) {
+ this.onSelectAllChange({ value: false });
+ }
+ }
+
+ getSelectedIds = () => {
+ if (this.state.allUnselected) {
+ return [];
+ }
+ return getSelectedIds(this.state.selectedState);
+ };
+
+ setSelectedState() {
+ const {
+ items
+ } = this.props;
+
+ const {
+ selectedState
+ } = this.state;
+
+ const newSelectedState = {};
+
+ items.forEach((file) => {
+ const isItemSelected = selectedState[file.id];
+
+ if (isItemSelected) {
+ newSelectedState[file.id] = isItemSelected;
+ } else {
+ newSelectedState[file.id] = false;
+ }
+ });
+
+ const selectedCount = getSelectedIds(newSelectedState).length;
+ const newStateCount = Object.keys(newSelectedState).length;
+ let isAllSelected = false;
+ let isAllUnselected = false;
+
+ if (selectedCount === 0) {
+ isAllUnselected = true;
+ } else if (selectedCount === newStateCount) {
+ isAllSelected = true;
+ }
+
+ this.setState({ selectedState: newSelectedState, allSelected: isAllSelected, allUnselected: isAllUnselected });
+ }
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ };
+
+ onSelectAllPress = () => {
+ this.onSelectAllChange({ value: !this.state.allSelected });
+ };
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ };
+
+ onDeleteUnmappedFilesPress = () => {
+ const selectedIds = this.getSelectedIds();
+
+ this.props.deleteUnmappedFiles(selectedIds);
+ };
+
+ rowRenderer = ({ key, rowIndex, style }) => {
+ const {
+ items,
+ columns,
+ deleteUnmappedFile
+ } = this.props;
+
+ const {
+ selectedState
+ } = this.state;
+
+ const item = items[rowIndex];
+
+ return (
+
+
+
+ );
+ };
+
+ render() {
+
+ const {
+ isFetching,
+ isPopulated,
+ isDeleting,
+ error,
+ items,
+ columns,
+ sortKey,
+ sortDirection,
+ onTableOptionChange,
+ onSortPress,
+ isScanningFolders,
+ onAddMissingArtistsPress,
+ deleteUnmappedFiles,
+ ...otherProps
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState
+ } = this.state;
+
+ const selectedTrackFileIds = this.getSelectedIds();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ isPopulated && !error && !items.length &&
+
+ Success! My work is done, all files on disk are matched to known tracks.
+
+ }
+
+ {
+ isPopulated &&
+ !error &&
+ !!items.length &&
+ this.scrollerRef.current ?
+
+ }
+ selectedState={selectedState}
+ sortKey={sortKey}
+ sortDirection={sortDirection}
+ /> :
+ null
+ }
+
+
+ );
+ }
+}
+
+UnmappedFilesTable.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ isDeleting: PropTypes.bool.isRequired,
+ deleteError: PropTypes.object,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ onTableOptionChange: PropTypes.func.isRequired,
+ onSortPress: PropTypes.func.isRequired,
+ deleteUnmappedFile: PropTypes.func.isRequired,
+ deleteUnmappedFiles: PropTypes.func.isRequired,
+ isScanningFolders: PropTypes.bool.isRequired,
+ onAddMissingArtistsPress: PropTypes.func.isRequired
+};
+
+export default UnmappedFilesTable;
diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableConnector.js b/frontend/src/UnmappedFiles/UnmappedFilesTableConnector.js
new file mode 100644
index 000000000..63484b210
--- /dev/null
+++ b/frontend/src/UnmappedFiles/UnmappedFilesTableConnector.js
@@ -0,0 +1,120 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import * as commandNames from 'Commands/commandNames';
+import withCurrentPage from 'Components/withCurrentPage';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { deleteTrackFile, deleteTrackFiles, fetchTrackFiles, setTrackFilesSort, setTrackFilesTableOption } from 'Store/Actions/trackFileActions';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import UnmappedFilesTable from './UnmappedFilesTable';
+
+function createMapStateToProps() {
+ return createSelector(
+ createClientSideCollectionSelector('trackFiles'),
+ createCommandExecutingSelector(commandNames.RESCAN_FOLDERS),
+ createDimensionsSelector(),
+ (
+ trackFiles,
+ isScanningFolders,
+ dimensionsState
+ ) => {
+ // trackFiles could pick up mapped entries via signalR so filter again here
+ const {
+ items,
+ ...otherProps
+ } = trackFiles;
+
+ const unmappedFiles = _.filter(items, { albumId: 0 });
+
+ return {
+ items: unmappedFiles,
+ ...otherProps,
+ isScanningFolders,
+ isSmallScreen: dimensionsState.isSmallScreen
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onTableOptionChange(payload) {
+ dispatch(setTrackFilesTableOption(payload));
+ },
+
+ onSortPress(sortKey) {
+ dispatch(setTrackFilesSort({ sortKey }));
+ },
+
+ fetchUnmappedFiles() {
+ dispatch(fetchTrackFiles({ unmapped: true }));
+ },
+
+ deleteUnmappedFile(id) {
+ dispatch(deleteTrackFile({ id }));
+ },
+
+ deleteUnmappedFiles(trackFileIds) {
+ dispatch(deleteTrackFiles({ trackFileIds }));
+ },
+
+ onAddMissingArtistsPress() {
+ dispatch(executeCommand({
+ name: commandNames.RESCAN_FOLDERS,
+ filter: 'matched'
+ }));
+ }
+ };
+}
+
+class UnmappedFilesTableConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ registerPagePopulator(this.repopulate, ['trackFileUpdated']);
+
+ this.repopulate();
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ }
+
+ //
+ // Control
+
+ repopulate = () => {
+ this.props.fetchUnmappedFiles();
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+UnmappedFilesTableConnector.propTypes = {
+ isSmallScreen: PropTypes.bool.isRequired,
+ onSortPress: PropTypes.func.isRequired,
+ onTableOptionChange: PropTypes.func.isRequired,
+ fetchUnmappedFiles: PropTypes.func.isRequired,
+ deleteUnmappedFile: PropTypes.func.isRequired,
+ deleteUnmappedFiles: PropTypes.func.isRequired
+};
+
+export default withCurrentPage(
+ connect(createMapStateToProps, createMapDispatchToProps)(UnmappedFilesTableConnector)
+);
diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.css b/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.css
new file mode 100644
index 000000000..184c69af1
--- /dev/null
+++ b/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.css
@@ -0,0 +1,19 @@
+.quality,
+.size,
+.dateAdded {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 120px;
+}
+
+.path {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 4 0 400px;
+}
+
+.actions {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 1 95px;
+}
diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.css.d.ts b/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.css.d.ts
new file mode 100644
index 000000000..551eacade
--- /dev/null
+++ b/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.css.d.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'actions': string;
+ 'dateAdded': string;
+ 'path': string;
+ 'quality': string;
+ 'size': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.js b/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.js
new file mode 100644
index 000000000..3b0715654
--- /dev/null
+++ b/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.js
@@ -0,0 +1,95 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import IconButton from 'Components/Link/IconButton';
+import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
+import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
+import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
+import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
+import { icons } from 'Helpers/Props';
+// import hasGrowableColumns from './hasGrowableColumns';
+import styles from './UnmappedFilesTableHeader.css';
+
+function UnmappedFilesTableHeader(props) {
+ const {
+ columns,
+ onTableOptionChange,
+ allSelected,
+ allUnselected,
+ onSelectAllChange,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ label,
+ isSortable,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'select') {
+ return (
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {typeof label === 'function' ? label() : label}
+
+ );
+ })
+ }
+
+ );
+}
+
+UnmappedFilesTableHeader.propTypes = {
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ allSelected: PropTypes.bool.isRequired,
+ allUnselected: PropTypes.bool.isRequired,
+ onSelectAllChange: PropTypes.func.isRequired,
+ onTableOptionChange: PropTypes.func.isRequired
+};
+
+export default UnmappedFilesTableHeader;
diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableRow.css b/frontend/src/UnmappedFiles/UnmappedFilesTableRow.css
new file mode 100644
index 000000000..e89cfbc31
--- /dev/null
+++ b/frontend/src/UnmappedFiles/UnmappedFilesTableRow.css
@@ -0,0 +1,28 @@
+.path {
+ composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 4 0 400px;
+ font-size: 13px;
+ font-family: $monoSpaceFontFamily;
+}
+
+.quality,
+.dateAdded,
+.size {
+ composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 120px;
+ white-space: nowrap;
+}
+
+.actions {
+ composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 95px;
+}
+
+.checkInput {
+ composes: input from '~Components/Form/CheckInput.css';
+
+ margin-top: 0;
+}
diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableRow.css.d.ts b/frontend/src/UnmappedFiles/UnmappedFilesTableRow.css.d.ts
new file mode 100644
index 000000000..b455e5f7a
--- /dev/null
+++ b/frontend/src/UnmappedFiles/UnmappedFilesTableRow.css.d.ts
@@ -0,0 +1,12 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'actions': string;
+ 'checkInput': string;
+ 'dateAdded': string;
+ 'path': string;
+ 'quality': string;
+ 'size': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableRow.js b/frontend/src/UnmappedFiles/UnmappedFilesTableRow.js
new file mode 100644
index 000000000..7b84a04a5
--- /dev/null
+++ b/frontend/src/UnmappedFiles/UnmappedFilesTableRow.js
@@ -0,0 +1,232 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TrackQuality from 'Album/TrackQuality';
+import IconButton from 'Components/Link/IconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
+import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
+import { icons, kinds } from 'Helpers/Props';
+import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
+import FileDetailsModal from 'TrackFile/FileDetailsModal';
+import formatBytes from 'Utilities/Number/formatBytes';
+import translate from 'Utilities/String/translate';
+import styles from './UnmappedFilesTableRow.css';
+
+class UnmappedFilesTableRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false,
+ isInteractiveImportModalOpen: false,
+ isConfirmDeleteModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onDetailsPress = () => {
+ this.setState({ isDetailsModalOpen: true });
+ };
+
+ onDetailsModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ };
+
+ onInteractiveImportPress = () => {
+ this.setState({ isInteractiveImportModalOpen: true });
+ };
+
+ onInteractiveImportModalClose = () => {
+ this.setState({ isInteractiveImportModalOpen: false });
+ };
+
+ onDeleteFilePress = () => {
+ this.setState({ isConfirmDeleteModalOpen: true });
+ };
+
+ onConfirmDelete = () => {
+ this.setState({ isConfirmDeleteModalOpen: false });
+ this.props.deleteUnmappedFile(this.props.id);
+ };
+
+ onConfirmDeleteModalClose = () => {
+ this.setState({ isConfirmDeleteModalOpen: false });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ path,
+ size,
+ dateAdded,
+ quality,
+ columns,
+ isSelected,
+ onSelectedChange
+ } = this.props;
+
+ const folder = path.substring(0, Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')));
+
+ const {
+ isInteractiveImportModalOpen,
+ isDetailsModalOpen,
+ isConfirmDeleteModalOpen
+ } = this.state;
+
+ return (
+ <>
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'select') {
+ return (
+
+ );
+ }
+
+ if (name === 'path') {
+ return (
+
+ {path}
+
+ );
+ }
+
+ if (name === 'size') {
+ return (
+
+ {formatBytes(size)}
+
+ );
+ }
+
+ if (name === 'dateAdded') {
+ return (
+
+ );
+ }
+
+ if (name === 'quality') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+
+
+
+
+
+
+ >
+ );
+ }
+
+}
+
+UnmappedFilesTableRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ path: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ quality: PropTypes.object.isRequired,
+ dateAdded: PropTypes.string.isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired,
+ deleteUnmappedFile: PropTypes.func.isRequired
+};
+
+export default UnmappedFilesTableRow;
diff --git a/frontend/src/Utilities/Album/getNewAlbum.js b/frontend/src/Utilities/Album/getNewAlbum.js
new file mode 100644
index 000000000..608358dde
--- /dev/null
+++ b/frontend/src/Utilities/Album/getNewAlbum.js
@@ -0,0 +1,20 @@
+import getNewArtist from 'Utilities/Artist/getNewArtist';
+
+function getNewAlbum(album, payload) {
+ const {
+ searchForNewAlbum = false
+ } = payload;
+
+ if (!('id' in album.artist) || album.artist.id === 0) {
+ getNewArtist(album.artist, payload);
+ }
+
+ album.addOptions = {
+ searchForNewAlbum
+ };
+ album.monitored = true;
+
+ return album;
+}
+
+export default getNewAlbum;
diff --git a/frontend/src/Utilities/Album/updateAlbums.js b/frontend/src/Utilities/Album/updateAlbums.js
new file mode 100644
index 000000000..259ef510e
--- /dev/null
+++ b/frontend/src/Utilities/Album/updateAlbums.js
@@ -0,0 +1,21 @@
+import _ from 'lodash';
+import { update } from 'Store/Actions/baseActions';
+
+function updateAlbums(section, albums, albumIds, options) {
+ const data = _.reduce(albums, (result, item) => {
+ if (albumIds.indexOf(item.id) > -1) {
+ result.push({
+ ...item,
+ ...options
+ });
+ } else {
+ result.push(item);
+ }
+
+ return result;
+ }, []);
+
+ return update({ section, data });
+}
+
+export default updateAlbums;
diff --git a/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js
new file mode 100644
index 000000000..cec7fb09a
--- /dev/null
+++ b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js
@@ -0,0 +1,11 @@
+export default function getIndexOfFirstCharacter(items, character) {
+ return items.findIndex((item) => {
+ const firstCharacter = item.sortName.charAt(0);
+
+ if (character === '#') {
+ return !isNaN(firstCharacter);
+ }
+
+ return firstCharacter === character;
+ });
+}
diff --git a/frontend/src/Utilities/Array/sortByProp.ts b/frontend/src/Utilities/Array/sortByProp.ts
new file mode 100644
index 000000000..8fbde08c9
--- /dev/null
+++ b/frontend/src/Utilities/Array/sortByProp.ts
@@ -0,0 +1,13 @@
+import { StringKey } from 'typings/Helpers/KeysMatching';
+
+export function sortByProp<
+ // eslint-disable-next-line no-use-before-define
+ T extends Record,
+ K extends StringKey
+>(sortKey: K) {
+ return (a: T, b: T) => {
+ return a[sortKey].localeCompare(b[sortKey], undefined, { numeric: true });
+ };
+}
+
+export default sortByProp;
diff --git a/frontend/src/Utilities/Artist/getNewArtist.js b/frontend/src/Utilities/Artist/getNewArtist.js
new file mode 100644
index 000000000..ed1a6fb11
--- /dev/null
+++ b/frontend/src/Utilities/Artist/getNewArtist.js
@@ -0,0 +1,31 @@
+
+function getNewArtist(artist, payload) {
+ const {
+ rootFolderPath,
+ monitor,
+ monitorNewItems,
+ qualityProfileId,
+ metadataProfileId,
+ artistType,
+ tags,
+ searchForMissingAlbums = false
+ } = payload;
+
+ const addOptions = {
+ monitor,
+ searchForMissingAlbums
+ };
+
+ artist.addOptions = addOptions;
+ artist.monitored = true;
+ artist.monitorNewItems = monitorNewItems;
+ artist.qualityProfileId = qualityProfileId;
+ artist.metadataProfileId = metadataProfileId;
+ artist.rootFolderPath = rootFolderPath;
+ artist.artistType = artistType;
+ artist.tags = tags;
+
+ return artist;
+}
+
+export default getNewArtist;
diff --git a/frontend/src/Utilities/Artist/getProgressBarKind.ts b/frontend/src/Utilities/Artist/getProgressBarKind.ts
new file mode 100644
index 000000000..f45387024
--- /dev/null
+++ b/frontend/src/Utilities/Artist/getProgressBarKind.ts
@@ -0,0 +1,24 @@
+import { kinds } from 'Helpers/Props';
+
+function getProgressBarKind(
+ status: string,
+ monitored: boolean,
+ progress: number,
+ isDownloading: boolean
+) {
+ if (isDownloading) {
+ return kinds.PURPLE;
+ }
+
+ if (progress === 100) {
+ return status === 'ended' ? kinds.SUCCESS : kinds.PRIMARY;
+ }
+
+ if (monitored) {
+ return kinds.DANGER;
+ }
+
+ return kinds.WARNING;
+}
+
+export default getProgressBarKind;
diff --git a/frontend/src/Utilities/Artist/monitorNewItemsOptions.js b/frontend/src/Utilities/Artist/monitorNewItemsOptions.js
new file mode 100644
index 000000000..f45095b6e
--- /dev/null
+++ b/frontend/src/Utilities/Artist/monitorNewItemsOptions.js
@@ -0,0 +1,24 @@
+import translate from 'Utilities/String/translate';
+
+const monitorNewItemsOptions = [
+ {
+ key: 'all',
+ get value() {
+ return translate('MonitorAllAlbums');
+ }
+ },
+ {
+ key: 'none',
+ get value() {
+ return translate('MonitorNoNewAlbums');
+ }
+ },
+ {
+ key: 'new',
+ get value() {
+ return translate('MonitorNewAlbums');
+ }
+ }
+];
+
+export default monitorNewItemsOptions;
diff --git a/frontend/src/Utilities/Artist/monitorOptions.js b/frontend/src/Utilities/Artist/monitorOptions.js
new file mode 100644
index 000000000..a06a79a96
--- /dev/null
+++ b/frontend/src/Utilities/Artist/monitorOptions.js
@@ -0,0 +1,48 @@
+import translate from 'Utilities/String/translate';
+
+const monitorOptions = [
+ {
+ key: 'all',
+ get value() {
+ return translate('MonitorAllAlbums');
+ }
+ },
+ {
+ key: 'future',
+ get value() {
+ return translate('MonitorFutureAlbums');
+ }
+ },
+ {
+ key: 'missing',
+ get value() {
+ return translate('MonitorMissingAlbums');
+ }
+ },
+ {
+ key: 'existing',
+ get value() {
+ return translate('MonitorExistingAlbums');
+ }
+ },
+ {
+ key: 'first',
+ get value() {
+ return translate('MonitorFirstAlbum');
+ }
+ },
+ {
+ key: 'latest',
+ get value() {
+ return translate('MonitorLastestAlbum');
+ }
+ },
+ {
+ key: 'none',
+ get value() {
+ return translate('MonitorNoAlbums');
+ }
+ }
+];
+
+export default monitorOptions;
diff --git a/frontend/src/Utilities/Command/findCommand.js b/frontend/src/Utilities/Command/findCommand.js
new file mode 100644
index 000000000..cf7d5444a
--- /dev/null
+++ b/frontend/src/Utilities/Command/findCommand.js
@@ -0,0 +1,10 @@
+import _ from 'lodash';
+import isSameCommand from './isSameCommand';
+
+function findCommand(commands, options) {
+ return _.findLast(commands, (command) => {
+ return isSameCommand(command.body, options);
+ });
+}
+
+export default findCommand;
diff --git a/frontend/src/Utilities/Command/index.js b/frontend/src/Utilities/Command/index.js
new file mode 100644
index 000000000..66043bf03
--- /dev/null
+++ b/frontend/src/Utilities/Command/index.js
@@ -0,0 +1,5 @@
+export { default as findCommand } from './findCommand';
+export { default as isCommandComplete } from './isCommandComplete';
+export { default as isCommandExecuting } from './isCommandExecuting';
+export { default as isCommandFailed } from './isCommandFailed';
+export { default as isSameCommand } from './isSameCommand';
diff --git a/frontend/src/Utilities/Command/isCommandComplete.js b/frontend/src/Utilities/Command/isCommandComplete.js
new file mode 100644
index 000000000..558ab801b
--- /dev/null
+++ b/frontend/src/Utilities/Command/isCommandComplete.js
@@ -0,0 +1,9 @@
+function isCommandComplete(command) {
+ if (!command) {
+ return false;
+ }
+
+ return command.status === 'complete';
+}
+
+export default isCommandComplete;
diff --git a/frontend/src/Utilities/Command/isCommandExecuting.js b/frontend/src/Utilities/Command/isCommandExecuting.js
new file mode 100644
index 000000000..8e637704e
--- /dev/null
+++ b/frontend/src/Utilities/Command/isCommandExecuting.js
@@ -0,0 +1,9 @@
+function isCommandExecuting(command) {
+ if (!command) {
+ return false;
+ }
+
+ return command.status === 'queued' || command.status === 'started';
+}
+
+export default isCommandExecuting;
diff --git a/frontend/src/Utilities/Command/isCommandFailed.js b/frontend/src/Utilities/Command/isCommandFailed.js
new file mode 100644
index 000000000..00e5ccdf2
--- /dev/null
+++ b/frontend/src/Utilities/Command/isCommandFailed.js
@@ -0,0 +1,12 @@
+function isCommandFailed(command) {
+ if (!command) {
+ return false;
+ }
+
+ return command.status === 'failed' ||
+ command.status === 'aborted' ||
+ command.status === 'cancelled' ||
+ command.status === 'orphaned';
+}
+
+export default isCommandFailed;
diff --git a/frontend/src/Utilities/Command/isSameCommand.js b/frontend/src/Utilities/Command/isSameCommand.js
new file mode 100644
index 000000000..d0acb24b5
--- /dev/null
+++ b/frontend/src/Utilities/Command/isSameCommand.js
@@ -0,0 +1,24 @@
+import _ from 'lodash';
+
+function isSameCommand(commandA, commandB) {
+ if (commandA.name.toLocaleLowerCase() !== commandB.name.toLocaleLowerCase()) {
+ return false;
+ }
+
+ for (const key in commandB) {
+ if (key !== 'name') {
+ const value = commandB[key];
+ if (Array.isArray(value)) {
+ if (_.difference(value, commandA[key]).length > 0) {
+ return false;
+ }
+ } else if (value !== commandA[key]) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+}
+
+export default isSameCommand;
diff --git a/frontend/src/Utilities/Constants/keyCodes.js b/frontend/src/Utilities/Constants/keyCodes.js
new file mode 100644
index 000000000..9285b10fe
--- /dev/null
+++ b/frontend/src/Utilities/Constants/keyCodes.js
@@ -0,0 +1,7 @@
+export const TAB = 9;
+export const ENTER = 13;
+export const SHIFT = 16;
+export const CONTROL = 17;
+export const ESCAPE = 27;
+export const UP_ARROW = 38;
+export const DOWN_ARROW = 40;
diff --git a/frontend/src/Utilities/Date/dateFilterPredicate.js b/frontend/src/Utilities/Date/dateFilterPredicate.js
new file mode 100644
index 000000000..59407e3ba
--- /dev/null
+++ b/frontend/src/Utilities/Date/dateFilterPredicate.js
@@ -0,0 +1,43 @@
+import moment from 'moment';
+import * as filterTypes from 'Helpers/Props/filterTypes';
+import isAfter from 'Utilities/Date/isAfter';
+import isBefore from 'Utilities/Date/isBefore';
+
+export default function(itemValue, filterValue, type) {
+ if (!itemValue) {
+ return false;
+ }
+
+ switch (type) {
+ case filterTypes.LESS_THAN:
+ return moment(itemValue).isBefore(filterValue);
+
+ case filterTypes.GREATER_THAN:
+ return moment(itemValue).isAfter(filterValue);
+
+ case filterTypes.IN_LAST:
+ return (
+ isAfter(itemValue, { [filterValue.time]: filterValue.value * -1 }) &&
+ isBefore(itemValue)
+ );
+
+ case filterTypes.NOT_IN_LAST:
+ return (
+ isBefore(itemValue, { [filterValue.time]: filterValue.value * -1 })
+ );
+
+ case filterTypes.IN_NEXT:
+ return (
+ isAfter(itemValue) &&
+ isBefore(itemValue, { [filterValue.time]: filterValue.value })
+ );
+
+ case filterTypes.NOT_IN_NEXT:
+ return (
+ isAfter(itemValue, { [filterValue.time]: filterValue.value })
+ );
+
+ default:
+ return false;
+ }
+}
diff --git a/frontend/src/Utilities/Date/formatDate.js b/frontend/src/Utilities/Date/formatDate.js
new file mode 100644
index 000000000..92eb57840
--- /dev/null
+++ b/frontend/src/Utilities/Date/formatDate.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function formatDate(date, dateFormat) {
+ if (!date) {
+ return '';
+ }
+
+ return moment(date).format(dateFormat);
+}
+
+export default formatDate;
diff --git a/frontend/src/Utilities/Date/formatDateTime.js b/frontend/src/Utilities/Date/formatDateTime.js
new file mode 100644
index 000000000..fb50230e1
--- /dev/null
+++ b/frontend/src/Utilities/Date/formatDateTime.js
@@ -0,0 +1,43 @@
+import moment from 'moment';
+import translate from 'Utilities/String/translate';
+import formatTime from './formatTime';
+import isToday from './isToday';
+import isTomorrow from './isTomorrow';
+import isYesterday from './isYesterday';
+
+function getRelativeDay(date, includeRelativeDate) {
+ if (!includeRelativeDate) {
+ return '';
+ }
+
+ if (isYesterday(date)) {
+ return translate('Yesterday');
+ }
+
+ if (isToday(date)) {
+ return translate('Today');
+ }
+
+ if (isTomorrow(date)) {
+ return translate('Tomorrow');
+ }
+
+ return '';
+}
+
+function formatDateTime(date, dateFormat, timeFormat, { includeSeconds = false, includeRelativeDay = false } = {}) {
+ if (!date) {
+ return '';
+ }
+
+ const relativeDay = getRelativeDay(date, includeRelativeDay);
+ const formattedDate = moment(date).format(dateFormat);
+ const formattedTime = formatTime(date, timeFormat, { includeMinuteZero: true, includeSeconds });
+
+ if (relativeDay) {
+ return translate('FormatDateTimeRelative', { relativeDay, formattedDate, formattedTime });
+ }
+ return translate('FormatDateTime', { formattedDate, formattedTime });
+}
+
+export default formatDateTime;
diff --git a/frontend/src/Utilities/Date/formatShortTimeSpan.js b/frontend/src/Utilities/Date/formatShortTimeSpan.js
new file mode 100644
index 000000000..148dc2627
--- /dev/null
+++ b/frontend/src/Utilities/Date/formatShortTimeSpan.js
@@ -0,0 +1,26 @@
+import moment from 'moment';
+import translate from 'Utilities/String/translate';
+
+function formatShortTimeSpan(timeSpan) {
+ if (!timeSpan) {
+ return '';
+ }
+
+ const duration = moment.duration(timeSpan);
+
+ const hours = Math.floor(duration.asHours());
+ const minutes = Math.floor(duration.asMinutes());
+ const seconds = Math.floor(duration.asSeconds());
+
+ if (hours > 0) {
+ return translate('FormatShortTimeSpanHours', { hours });
+ }
+
+ if (minutes > 0) {
+ return translate('FormatShortTimeSpanMinutes', { minutes });
+ }
+
+ return translate('FormatShortTimeSpanSeconds', { seconds });
+}
+
+export default formatShortTimeSpan;
diff --git a/frontend/src/Utilities/Date/formatTime.js b/frontend/src/Utilities/Date/formatTime.js
new file mode 100644
index 000000000..226b0cd97
--- /dev/null
+++ b/frontend/src/Utilities/Date/formatTime.js
@@ -0,0 +1,21 @@
+import moment from 'moment';
+
+function formatTime(date, timeFormat, { includeMinuteZero = false, includeSeconds = false } = {}) {
+ if (!date) {
+ return '';
+ }
+
+ const time = moment(date);
+
+ if (includeSeconds) {
+ timeFormat = timeFormat.replace(/\(?:mm\)?/, ':mm:ss');
+ } else if (includeMinuteZero || time.minute() !== 0) {
+ timeFormat = timeFormat.replace('(:mm)', ':mm');
+ } else {
+ timeFormat = timeFormat.replace('(:mm)', '');
+ }
+
+ return time.format(timeFormat);
+}
+
+export default formatTime;
diff --git a/frontend/src/Utilities/Date/formatTimeSpan.js b/frontend/src/Utilities/Date/formatTimeSpan.js
new file mode 100644
index 000000000..2422e19d5
--- /dev/null
+++ b/frontend/src/Utilities/Date/formatTimeSpan.js
@@ -0,0 +1,26 @@
+import moment from 'moment';
+import padNumber from 'Utilities/Number/padNumber';
+import translate from 'Utilities/String/translate';
+
+function formatTimeSpan(timeSpan) {
+ if (!timeSpan) {
+ return '';
+ }
+
+ const duration = moment.duration(timeSpan);
+
+ const days = Math.floor(duration.asDays());
+ const hours = padNumber(duration.get('hours'), 2);
+ const minutes = padNumber(duration.get('minutes'), 2);
+ const seconds = padNumber(duration.get('seconds'), 2);
+
+ const time = `${hours}:${minutes}:${seconds}`;
+
+ if (days > 0) {
+ return translate('FormatTimeSpanDays', { days, time });
+ }
+
+ return time;
+}
+
+export default formatTimeSpan;
diff --git a/frontend/src/Utilities/Date/getRelativeDate.ts b/frontend/src/Utilities/Date/getRelativeDate.ts
new file mode 100644
index 000000000..178d14fb7
--- /dev/null
+++ b/frontend/src/Utilities/Date/getRelativeDate.ts
@@ -0,0 +1,61 @@
+import moment from 'moment';
+import formatTime from 'Utilities/Date/formatTime';
+import isInNextWeek from 'Utilities/Date/isInNextWeek';
+import isToday from 'Utilities/Date/isToday';
+import isTomorrow from 'Utilities/Date/isTomorrow';
+import isYesterday from 'Utilities/Date/isYesterday';
+import translate from 'Utilities/String/translate';
+
+interface GetRelativeDateOptions {
+ timeFormat?: string;
+ includeSeconds?: boolean;
+ timeForToday?: boolean;
+}
+
+function getRelativeDate(
+ date: string | undefined,
+ shortDateFormat: string,
+ showRelativeDates: boolean,
+ {
+ timeFormat,
+ includeSeconds = false,
+ timeForToday = false,
+ }: GetRelativeDateOptions = {}
+) {
+ if (!date) {
+ return '';
+ }
+
+ const isTodayDate = isToday(date);
+
+ if (isTodayDate && timeForToday && timeFormat) {
+ return formatTime(date, timeFormat, {
+ includeMinuteZero: true,
+ includeSeconds,
+ });
+ }
+
+ if (!showRelativeDates) {
+ return moment(date).format(shortDateFormat);
+ }
+
+ if (isYesterday(date)) {
+ return translate('Yesterday');
+ }
+
+ if (isTodayDate) {
+ return translate('Today');
+ }
+
+ if (isTomorrow(date)) {
+ return translate('Tomorrow');
+ }
+
+ if (isInNextWeek(date)) {
+ return moment(date).format('dddd');
+ }
+
+ return moment(date).format(shortDateFormat);
+}
+
+export default getRelativeDate;
diff --git a/frontend/src/Utilities/Date/isAfter.js b/frontend/src/Utilities/Date/isAfter.js
new file mode 100644
index 000000000..4bbd8660b
--- /dev/null
+++ b/frontend/src/Utilities/Date/isAfter.js
@@ -0,0 +1,17 @@
+import moment from 'moment';
+
+function isAfter(date, offsets = {}) {
+ if (!date) {
+ return false;
+ }
+
+ const offsetTime = moment();
+
+ Object.keys(offsets).forEach((key) => {
+ offsetTime.add(offsets[key], key);
+ });
+
+ return moment(date).isAfter(offsetTime);
+}
+
+export default isAfter;
diff --git a/frontend/src/Utilities/Date/isBefore.js b/frontend/src/Utilities/Date/isBefore.js
new file mode 100644
index 000000000..3e1e81f67
--- /dev/null
+++ b/frontend/src/Utilities/Date/isBefore.js
@@ -0,0 +1,17 @@
+import moment from 'moment';
+
+function isBefore(date, offsets = {}) {
+ if (!date) {
+ return false;
+ }
+
+ const offsetTime = moment();
+
+ Object.keys(offsets).forEach((key) => {
+ offsetTime.add(offsets[key], key);
+ });
+
+ return moment(date).isBefore(offsetTime);
+}
+
+export default isBefore;
diff --git a/frontend/src/Utilities/Date/isInNextWeek.js b/frontend/src/Utilities/Date/isInNextWeek.js
new file mode 100644
index 000000000..7b5fd7cc7
--- /dev/null
+++ b/frontend/src/Utilities/Date/isInNextWeek.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function isInNextWeek(date) {
+ if (!date) {
+ return false;
+ }
+ const now = moment();
+ return moment(date).isBetween(now, now.clone().add(6, 'days').endOf('day'));
+}
+
+export default isInNextWeek;
diff --git a/frontend/src/Utilities/Date/isSameWeek.js b/frontend/src/Utilities/Date/isSameWeek.js
new file mode 100644
index 000000000..14b76ffb7
--- /dev/null
+++ b/frontend/src/Utilities/Date/isSameWeek.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function isSameWeek(date) {
+ if (!date) {
+ return false;
+ }
+
+ return moment(date).isSame(moment(), 'week');
+}
+
+export default isSameWeek;
diff --git a/frontend/src/Utilities/Date/isToday.js b/frontend/src/Utilities/Date/isToday.js
new file mode 100644
index 000000000..31502951f
--- /dev/null
+++ b/frontend/src/Utilities/Date/isToday.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function isToday(date) {
+ if (!date) {
+ return false;
+ }
+
+ return moment(date).isSame(moment(), 'day');
+}
+
+export default isToday;
diff --git a/frontend/src/Utilities/Date/isTomorrow.js b/frontend/src/Utilities/Date/isTomorrow.js
new file mode 100644
index 000000000..d22386dbd
--- /dev/null
+++ b/frontend/src/Utilities/Date/isTomorrow.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function isTomorrow(date) {
+ if (!date) {
+ return false;
+ }
+
+ return moment(date).isSame(moment().add(1, 'day'), 'day');
+}
+
+export default isTomorrow;
diff --git a/frontend/src/Utilities/Date/isYesterday.js b/frontend/src/Utilities/Date/isYesterday.js
new file mode 100644
index 000000000..9de21d82a
--- /dev/null
+++ b/frontend/src/Utilities/Date/isYesterday.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function isYesterday(date) {
+ if (!date) {
+ return false;
+ }
+
+ return moment(date).isSame(moment().subtract(1, 'day'), 'day');
+}
+
+export default isYesterday;
diff --git a/frontend/src/Utilities/Filter/findSelectedFilters.js b/frontend/src/Utilities/Filter/findSelectedFilters.js
new file mode 100644
index 000000000..1c104073c
--- /dev/null
+++ b/frontend/src/Utilities/Filter/findSelectedFilters.js
@@ -0,0 +1,19 @@
+export default function findSelectedFilters(selectedFilterKey, filters = [], customFilters = []) {
+ if (!selectedFilterKey) {
+ return [];
+ }
+
+ let selectedFilter = filters.find((f) => f.key === selectedFilterKey);
+
+ if (!selectedFilter) {
+ selectedFilter = customFilters.find((f) => f.id === selectedFilterKey);
+ }
+
+ if (!selectedFilter) {
+ // TODO: throw in dev
+ console.error('Matching filter not found');
+ return [];
+ }
+
+ return selectedFilter.filters;
+}
diff --git a/frontend/src/Utilities/Filter/getFilterValue.js b/frontend/src/Utilities/Filter/getFilterValue.js
new file mode 100644
index 000000000..70b0b51f1
--- /dev/null
+++ b/frontend/src/Utilities/Filter/getFilterValue.js
@@ -0,0 +1,11 @@
+export default function getFilterValue(filters, filterKey, filterValueKey, defaultValue) {
+ const filter = filters.find((f) => f.key === filterKey);
+
+ if (!filter) {
+ return defaultValue;
+ }
+
+ const filterValue = filter.filters.find((f) => f.key === filterValueKey);
+
+ return filterValue ? filterValue.value : defaultValue;
+}
diff --git a/frontend/src/Utilities/Number/convertToBytes.js b/frontend/src/Utilities/Number/convertToBytes.js
new file mode 100644
index 000000000..88357944f
--- /dev/null
+++ b/frontend/src/Utilities/Number/convertToBytes.js
@@ -0,0 +1,15 @@
+function convertToBytes(input, power, binaryPrefix) {
+ const size = Number(input);
+
+ if (isNaN(size)) {
+ return '';
+ }
+
+ const prefix = binaryPrefix ? 1024 : 1000;
+ const multiplier = Math.pow(prefix, power);
+ const result = size * multiplier;
+
+ return Math.round(result);
+}
+
+export default convertToBytes;
diff --git a/frontend/src/Utilities/Number/formatAge.js b/frontend/src/Utilities/Number/formatAge.js
new file mode 100644
index 000000000..a8f0e9f65
--- /dev/null
+++ b/frontend/src/Utilities/Number/formatAge.js
@@ -0,0 +1,19 @@
+import translate from 'Utilities/String/translate';
+
+function formatAge(age, ageHours, ageMinutes) {
+ age = Math.round(age);
+ ageHours = parseFloat(ageHours);
+ ageMinutes = ageMinutes && parseFloat(ageMinutes);
+
+ if (age < 2 && ageHours) {
+ if (ageHours < 2 && !!ageMinutes) {
+ return `${ageMinutes.toFixed(0)} ${ageHours === 1 ? translate('FormatAgeMinute') : translate('FormatAgeMinutes')}`;
+ }
+
+ return `${ageHours.toFixed(1)} ${ageHours === 1 ? translate('FormatAgeHour') : translate('FormatAgeHours')}`;
+ }
+
+ return `${age} ${age === 1 ? translate('FormatAgeDay') : translate('FormatAgeDays')}`;
+}
+
+export default formatAge;
diff --git a/frontend/src/Utilities/Number/formatBytes.js b/frontend/src/Utilities/Number/formatBytes.js
new file mode 100644
index 000000000..273452031
--- /dev/null
+++ b/frontend/src/Utilities/Number/formatBytes.js
@@ -0,0 +1,17 @@
+import { filesize } from 'filesize';
+
+function formatBytes(input, showBits = false) {
+ const size = Number(input);
+
+ if (isNaN(size)) {
+ return '';
+ }
+
+ return `${filesize(size, {
+ base: 2,
+ round: 1,
+ bits: showBits
+ })}`;
+}
+
+export default formatBytes;
diff --git a/frontend/src/Utilities/Number/formatCustomFormatScore.ts b/frontend/src/Utilities/Number/formatCustomFormatScore.ts
new file mode 100644
index 000000000..b4da3a2cd
--- /dev/null
+++ b/frontend/src/Utilities/Number/formatCustomFormatScore.ts
@@ -0,0 +1,18 @@
+function formatCustomFormatScore(
+ input?: number,
+ customFormatsLength = 0
+): string {
+ const score = Number(input);
+
+ if (score > 0) {
+ return `+${score}`;
+ }
+
+ if (score < 0) {
+ return `${score}`;
+ }
+
+ return customFormatsLength > 0 ? '+0' : '';
+}
+
+export default formatCustomFormatScore;
diff --git a/frontend/src/Utilities/Number/padNumber.js b/frontend/src/Utilities/Number/padNumber.js
new file mode 100644
index 000000000..53ae69cac
--- /dev/null
+++ b/frontend/src/Utilities/Number/padNumber.js
@@ -0,0 +1,10 @@
+function padNumber(input, width, paddingCharacter = 0) {
+ if (input == null) {
+ return '';
+ }
+
+ input = `${input}`;
+ return input.length >= width ? input : new Array(width - input.length + 1).join(paddingCharacter) + input;
+}
+
+export default padNumber;
diff --git a/frontend/src/Utilities/Number/roundNumber.js b/frontend/src/Utilities/Number/roundNumber.js
new file mode 100644
index 000000000..e1a19018f
--- /dev/null
+++ b/frontend/src/Utilities/Number/roundNumber.js
@@ -0,0 +1,5 @@
+export default function roundNumber(input, decimalPlaces = 1) {
+ const multiplier = Math.pow(10, decimalPlaces);
+
+ return Math.round(input * multiplier) / multiplier;
+}
diff --git a/frontend/src/Utilities/Object/getErrorMessage.js b/frontend/src/Utilities/Object/getErrorMessage.js
new file mode 100644
index 000000000..1ba874660
--- /dev/null
+++ b/frontend/src/Utilities/Object/getErrorMessage.js
@@ -0,0 +1,11 @@
+function getErrorMessage(xhr, fallbackErrorMessage) {
+ if (!xhr || !xhr.responseJSON || !xhr.responseJSON.message) {
+ return fallbackErrorMessage;
+ }
+
+ const message = xhr.responseJSON.message;
+
+ return message || fallbackErrorMessage;
+}
+
+export default getErrorMessage;
diff --git a/frontend/src/Utilities/Object/getRemovedItems.js b/frontend/src/Utilities/Object/getRemovedItems.js
new file mode 100644
index 000000000..df7ada3a8
--- /dev/null
+++ b/frontend/src/Utilities/Object/getRemovedItems.js
@@ -0,0 +1,15 @@
+function getRemovedItems(prevItems, currentItems, idProp = 'id') {
+ if (prevItems === currentItems) {
+ return [];
+ }
+
+ const currentItemIds = new Set();
+
+ currentItems.forEach((currentItem) => {
+ currentItemIds.add(currentItem[idProp]);
+ });
+
+ return prevItems.filter((prevItem) => !currentItemIds.has(prevItem[idProp]));
+}
+
+export default getRemovedItems;
diff --git a/frontend/src/Utilities/Object/hasDifferentItems.js b/frontend/src/Utilities/Object/hasDifferentItems.js
new file mode 100644
index 000000000..d3c0046e4
--- /dev/null
+++ b/frontend/src/Utilities/Object/hasDifferentItems.js
@@ -0,0 +1,19 @@
+function hasDifferentItems(prevItems, currentItems, idProp = 'id') {
+ if (prevItems === currentItems) {
+ return false;
+ }
+
+ if (prevItems.length !== currentItems.length) {
+ return true;
+ }
+
+ const currentItemIds = new Set();
+
+ currentItems.forEach((currentItem) => {
+ currentItemIds.add(currentItem[idProp]);
+ });
+
+ return prevItems.some((prevItem) => !currentItemIds.has(prevItem[idProp]));
+}
+
+export default hasDifferentItems;
diff --git a/frontend/src/Utilities/Object/hasDifferentItemsOrOrder.js b/frontend/src/Utilities/Object/hasDifferentItemsOrOrder.js
new file mode 100644
index 000000000..e2acbc5c0
--- /dev/null
+++ b/frontend/src/Utilities/Object/hasDifferentItemsOrOrder.js
@@ -0,0 +1,21 @@
+function hasDifferentItemsOrOrder(prevItems, currentItems, idProp = 'id') {
+ if (prevItems === currentItems) {
+ return false;
+ }
+
+ const len = prevItems.length;
+
+ if (len !== currentItems.length) {
+ return true;
+ }
+
+ for (let i = 0; i < len; i++) {
+ if (prevItems[i][idProp] !== currentItems[i][idProp]) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+export default hasDifferentItemsOrOrder;
diff --git a/frontend/src/Utilities/Object/selectUniqueIds.js b/frontend/src/Utilities/Object/selectUniqueIds.js
new file mode 100644
index 000000000..c2c0c17e3
--- /dev/null
+++ b/frontend/src/Utilities/Object/selectUniqueIds.js
@@ -0,0 +1,15 @@
+import _ from 'lodash';
+
+function selectUniqueIds(items, idProp) {
+ const ids = _.reduce(items, (result, item) => {
+ if (item[idProp]) {
+ result.push(item[idProp]);
+ }
+
+ return result;
+ }, []);
+
+ return _.uniq(ids);
+}
+
+export default selectUniqueIds;
diff --git a/frontend/src/Utilities/Quality/getQualities.js b/frontend/src/Utilities/Quality/getQualities.js
new file mode 100644
index 000000000..da09851ea
--- /dev/null
+++ b/frontend/src/Utilities/Quality/getQualities.js
@@ -0,0 +1,16 @@
+export default function getQualities(qualities) {
+ if (!qualities) {
+ return [];
+ }
+
+ return qualities.reduce((acc, item) => {
+ if (item.quality) {
+ acc.push(item.quality);
+ } else {
+ const groupQualities = item.items.map((i) => i.quality);
+ acc.push(...groupQualities);
+ }
+
+ return acc;
+ }, []);
+}
diff --git a/frontend/src/Utilities/ResolutionUtility.js b/frontend/src/Utilities/ResolutionUtility.js
new file mode 100644
index 000000000..358448ca9
--- /dev/null
+++ b/frontend/src/Utilities/ResolutionUtility.js
@@ -0,0 +1,26 @@
+import $ from 'jquery';
+
+module.exports = {
+ resolutions: {
+ desktopLarge: 1200,
+ desktop: 992,
+ tablet: 768,
+ mobile: 480
+ },
+
+ isDesktopLarge() {
+ return $(window).width() < this.resolutions.desktopLarge;
+ },
+
+ isDesktop() {
+ return $(window).width() < this.resolutions.desktop;
+ },
+
+ isTablet() {
+ return $(window).width() < this.resolutions.tablet;
+ },
+
+ isMobile() {
+ return $(window).width() < this.resolutions.mobile;
+ }
+};
diff --git a/frontend/src/Utilities/State/getNextId.js b/frontend/src/Utilities/State/getNextId.js
new file mode 100644
index 000000000..204aac95a
--- /dev/null
+++ b/frontend/src/Utilities/State/getNextId.js
@@ -0,0 +1,5 @@
+function getNextId(items) {
+ return items.reduce((id, x) => Math.max(id, x.id), 1) + 1;
+}
+
+export default getNextId;
diff --git a/frontend/src/Utilities/State/getProviderState.js b/frontend/src/Utilities/State/getProviderState.js
new file mode 100644
index 000000000..4159905b8
--- /dev/null
+++ b/frontend/src/Utilities/State/getProviderState.js
@@ -0,0 +1,49 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+
+function getProviderState(payload, getState, section, keyValueOnly=true) {
+ const {
+ id,
+ ...otherPayload
+ } = payload;
+
+ const state = getSectionState(getState(), section, true);
+ const pendingChanges = Object.assign({}, state.pendingChanges, otherPayload);
+ const pendingFields = state.pendingChanges.fields || {};
+ delete pendingChanges.fields;
+
+ const item = id ? _.find(state.items, { id }) : state.selectedSchema || state.schema || {};
+
+ if (item.fields) {
+ pendingChanges.fields = _.reduce(item.fields, (result, field) => {
+ const name = field.name;
+
+ const value = pendingFields.hasOwnProperty(name) ?
+ pendingFields[name] :
+ field.value;
+
+ // Only send the name and value to the server
+ if (keyValueOnly) {
+ result.push({
+ name,
+ value
+ });
+ } else {
+ result.push({
+ ...field,
+ value
+ });
+ }
+
+ return result;
+ }, []);
+ }
+
+ const result = Object.assign({}, item, pendingChanges);
+
+ delete result.presets;
+
+ return result;
+}
+
+export default getProviderState;
diff --git a/frontend/src/Utilities/State/getSectionState.js b/frontend/src/Utilities/State/getSectionState.js
new file mode 100644
index 000000000..00871bed2
--- /dev/null
+++ b/frontend/src/Utilities/State/getSectionState.js
@@ -0,0 +1,22 @@
+import _ from 'lodash';
+
+function getSectionState(state, section, isFullStateTree = false) {
+ if (isFullStateTree) {
+ return _.get(state, section);
+ }
+
+ const [, subSection] = section.split('.');
+
+ if (subSection) {
+ return Object.assign({}, state[subSection]);
+ }
+
+ // TODO: Remove in favour of using subSection
+ if (state.hasOwnProperty(section)) {
+ return Object.assign({}, state[section]);
+ }
+
+ return Object.assign({}, state);
+}
+
+export default getSectionState;
diff --git a/frontend/src/Utilities/State/selectProviderSchema.js b/frontend/src/Utilities/State/selectProviderSchema.js
new file mode 100644
index 000000000..c8a31760c
--- /dev/null
+++ b/frontend/src/Utilities/State/selectProviderSchema.js
@@ -0,0 +1,34 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function applySchemaDefaults(selectedSchema, schemaDefaults) {
+ if (!schemaDefaults) {
+ return selectedSchema;
+ } else if (_.isFunction(schemaDefaults)) {
+ return schemaDefaults(selectedSchema);
+ }
+
+ return Object.assign(selectedSchema, schemaDefaults);
+}
+
+function selectProviderSchema(state, section, payload, schemaDefaults) {
+ const newState = getSectionState(state, section);
+
+ const {
+ implementation,
+ presetName
+ } = payload;
+
+ const selectedImplementation = _.find(newState.schema, { implementation });
+
+ const selectedSchema = presetName ?
+ _.find(selectedImplementation.presets, { name: presetName }) :
+ selectedImplementation;
+
+ newState.selectedSchema = applySchemaDefaults(_.cloneDeep(selectedSchema), schemaDefaults);
+
+ return updateSectionState(state, section, newState);
+}
+
+export default selectProviderSchema;
diff --git a/frontend/src/Utilities/State/updateSectionState.js b/frontend/src/Utilities/State/updateSectionState.js
new file mode 100644
index 000000000..81b33ecaf
--- /dev/null
+++ b/frontend/src/Utilities/State/updateSectionState.js
@@ -0,0 +1,16 @@
+function updateSectionState(state, section, newState) {
+ const [, subSection] = section.split('.');
+
+ if (subSection) {
+ return Object.assign({}, state, { [subSection]: newState });
+ }
+
+ // TODO: Remove in favour of using subSection
+ if (state.hasOwnProperty(section)) {
+ return Object.assign({}, state, { [section]: newState });
+ }
+
+ return Object.assign({}, state, newState);
+}
+
+export default updateSectionState;
diff --git a/frontend/src/Utilities/String/combinePath.js b/frontend/src/Utilities/String/combinePath.js
new file mode 100644
index 000000000..9e4e9abe8
--- /dev/null
+++ b/frontend/src/Utilities/String/combinePath.js
@@ -0,0 +1,5 @@
+export default function combinePath(isWindows, basePath, paths = []) {
+ const slash = isWindows ? '\\' : '/';
+
+ return `${basePath}${slash}${paths.join(slash)}`;
+}
diff --git a/frontend/src/Utilities/String/firstCharToUpper.js b/frontend/src/Utilities/String/firstCharToUpper.js
new file mode 100644
index 000000000..1ce64831c
--- /dev/null
+++ b/frontend/src/Utilities/String/firstCharToUpper.js
@@ -0,0 +1,9 @@
+function firstCharToUpper(input) {
+ if (!input) {
+ return '';
+ }
+
+ return [].map.call(input, (char, i) => (i ? char : char.toUpperCase())).join('');
+}
+
+export default firstCharToUpper;
diff --git a/frontend/src/Utilities/String/generateUUIDv4.js b/frontend/src/Utilities/String/generateUUIDv4.js
new file mode 100644
index 000000000..51b15ec60
--- /dev/null
+++ b/frontend/src/Utilities/String/generateUUIDv4.js
@@ -0,0 +1,6 @@
+export default function generateUUIDv4() {
+ return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, (c) =>
+ // eslint-disable-next-line no-bitwise
+ (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
+ );
+}
diff --git a/frontend/src/Utilities/String/isString.js b/frontend/src/Utilities/String/isString.js
new file mode 100644
index 000000000..1e7c3dff8
--- /dev/null
+++ b/frontend/src/Utilities/String/isString.js
@@ -0,0 +1,3 @@
+export default function isString(possibleString) {
+ return typeof possibleString === 'string' || possibleString instanceof String;
+}
diff --git a/frontend/src/Utilities/String/naturalExpansion.js b/frontend/src/Utilities/String/naturalExpansion.js
new file mode 100644
index 000000000..2cdd69b86
--- /dev/null
+++ b/frontend/src/Utilities/String/naturalExpansion.js
@@ -0,0 +1,11 @@
+const regex = /\d+/g;
+
+function naturalExpansion(input) {
+ if (!input) {
+ return '';
+ }
+
+ return input.replace(regex, (n) => n.padStart(8, '0'));
+}
+
+export default naturalExpansion;
diff --git a/frontend/src/Utilities/String/parseUrl.js b/frontend/src/Utilities/String/parseUrl.js
new file mode 100644
index 000000000..93341f85f
--- /dev/null
+++ b/frontend/src/Utilities/String/parseUrl.js
@@ -0,0 +1,36 @@
+import _ from 'lodash';
+import qs from 'qs';
+
+// See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils
+const anchor = document.createElement('a');
+
+export default function parseUrl(url) {
+ anchor.href = url;
+
+ // The `origin`, `password`, and `username` properties are unavailable in
+ // Opera Presto. We synthesize `origin` if it's not present. While `password`
+ // and `username` are ignored intentionally.
+ const properties = _.pick(
+ anchor,
+ 'hash',
+ 'host',
+ 'hostname',
+ 'href',
+ 'origin',
+ 'pathname',
+ 'port',
+ 'protocol',
+ 'search'
+ );
+
+ properties.isAbsolute = (/^[\w:]*\/\//).test(url);
+
+ if (properties.search) {
+ // Remove leading ? from querystring before parsing.
+ properties.params = qs.parse(properties.search.substring(1));
+ } else {
+ properties.params = {};
+ }
+
+ return properties;
+}
diff --git a/frontend/src/Utilities/String/shortenList.js b/frontend/src/Utilities/String/shortenList.js
new file mode 100644
index 000000000..66085db4d
--- /dev/null
+++ b/frontend/src/Utilities/String/shortenList.js
@@ -0,0 +1,7 @@
+export default function shortenList(input, startCount = 3, endCount = 1, separator = ', ') {
+ const sorted = [...input].sort();
+ if (sorted.length <= startCount + endCount) {
+ return sorted.join(separator);
+ }
+ return [...sorted.slice(0, startCount), '...', sorted.slice(-endCount)].join(separator);
+}
diff --git a/frontend/src/Utilities/String/split.js b/frontend/src/Utilities/String/split.js
new file mode 100644
index 000000000..0e57e7545
--- /dev/null
+++ b/frontend/src/Utilities/String/split.js
@@ -0,0 +1,17 @@
+import _ from 'lodash';
+
+function split(input, separator = ',') {
+ if (!input) {
+ return [];
+ }
+
+ return _.reduce(input.split(separator), (result, s) => {
+ if (s) {
+ result.push(s);
+ }
+
+ return result;
+ }, []);
+}
+
+export default split;
diff --git a/frontend/src/Utilities/String/titleCase.js b/frontend/src/Utilities/String/titleCase.js
new file mode 100644
index 000000000..03573b9e3
--- /dev/null
+++ b/frontend/src/Utilities/String/titleCase.js
@@ -0,0 +1,13 @@
+const regex = /\b\w+/g;
+
+function titleCase(input) {
+ if (!input) {
+ return '';
+ }
+
+ return input.replace(regex, (match) => {
+ return match.charAt(0).toUpperCase() + match.substr(1).toLowerCase();
+ });
+}
+
+export default titleCase;
diff --git a/frontend/src/Utilities/String/translate.ts b/frontend/src/Utilities/String/translate.ts
new file mode 100644
index 000000000..5571ef6b0
--- /dev/null
+++ b/frontend/src/Utilities/String/translate.ts
@@ -0,0 +1,48 @@
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+
+function getTranslations() {
+ return createAjaxRequest({
+ global: false,
+ dataType: 'json',
+ url: '/localization',
+ }).request;
+}
+
+let translations: Record = {};
+
+export async function fetchTranslations(): Promise {
+ return new Promise(async (resolve) => {
+ try {
+ const data = await getTranslations();
+ translations = data.strings;
+
+ resolve(true);
+ } catch {
+ resolve(false);
+ }
+ });
+}
+
+export default function translate(
+ key: string,
+ tokens: Record = {}
+) {
+ const { isProduction = true } = window.Lidarr;
+
+ if (!isProduction && !(key in translations)) {
+ console.warn(`Missing translation for key: ${key}`);
+ }
+
+ const translation = translations[key] || key;
+
+ tokens.appName = 'Lidarr';
+
+ // Fallback to the old behaviour for translations not yet updated to use named tokens
+ Object.values(tokens).forEach((value, index) => {
+ tokens[index] = value;
+ });
+
+ return translation.replace(/\{([a-z0-9]+?)\}/gi, (match, tokenMatch) =>
+ String(tokens[tokenMatch] ?? match)
+ );
+}
diff --git a/frontend/src/Utilities/Table/areAllSelected.js b/frontend/src/Utilities/Table/areAllSelected.js
new file mode 100644
index 000000000..26102f89b
--- /dev/null
+++ b/frontend/src/Utilities/Table/areAllSelected.js
@@ -0,0 +1,17 @@
+export default function areAllSelected(selectedState) {
+ let allSelected = true;
+ let allUnselected = true;
+
+ Object.keys(selectedState).forEach((key) => {
+ if (selectedState[key]) {
+ allUnselected = false;
+ } else {
+ allSelected = false;
+ }
+ });
+
+ return {
+ allSelected,
+ allUnselected
+ };
+}
diff --git a/frontend/src/Utilities/Table/getSelectedIds.ts b/frontend/src/Utilities/Table/getSelectedIds.ts
new file mode 100644
index 000000000..b84db6245
--- /dev/null
+++ b/frontend/src/Utilities/Table/getSelectedIds.ts
@@ -0,0 +1,18 @@
+import { reduce } from 'lodash';
+import { SelectedState } from 'Helpers/Hooks/useSelectState';
+
+function getSelectedIds(selectedState: SelectedState): number[] {
+ return reduce(
+ selectedState,
+ (result: number[], value, id) => {
+ if (value) {
+ result.push(parseInt(id));
+ }
+
+ return result;
+ },
+ []
+ );
+}
+
+export default getSelectedIds;
diff --git a/frontend/src/Utilities/Table/getToggledRange.js b/frontend/src/Utilities/Table/getToggledRange.js
new file mode 100644
index 000000000..c0cc44fe5
--- /dev/null
+++ b/frontend/src/Utilities/Table/getToggledRange.js
@@ -0,0 +1,23 @@
+import _ from 'lodash';
+
+function getToggledRange(items, id, lastToggled) {
+ const lastToggledIndex = _.findIndex(items, { id: lastToggled });
+ const changedIndex = _.findIndex(items, { id });
+ let lower = 0;
+ let upper = 0;
+
+ if (lastToggledIndex > changedIndex) {
+ lower = changedIndex;
+ upper = lastToggledIndex + 1;
+ } else {
+ lower = lastToggledIndex;
+ upper = changedIndex;
+ }
+
+ return {
+ lower,
+ upper
+ };
+}
+
+export default getToggledRange;
diff --git a/frontend/src/Utilities/Table/removeOldSelectedState.js b/frontend/src/Utilities/Table/removeOldSelectedState.js
new file mode 100644
index 000000000..ff3a4fe11
--- /dev/null
+++ b/frontend/src/Utilities/Table/removeOldSelectedState.js
@@ -0,0 +1,16 @@
+import areAllSelected from './areAllSelected';
+
+export default function removeOldSelectedState(state, prevItems) {
+ const selectedState = {
+ ...state.selectedState
+ };
+
+ prevItems.forEach((item) => {
+ delete selectedState[item.id];
+ });
+
+ return {
+ ...areAllSelected(selectedState),
+ selectedState
+ };
+}
diff --git a/frontend/src/Utilities/Table/selectAll.js b/frontend/src/Utilities/Table/selectAll.js
new file mode 100644
index 000000000..ffaaeaddf
--- /dev/null
+++ b/frontend/src/Utilities/Table/selectAll.js
@@ -0,0 +1,17 @@
+import _ from 'lodash';
+
+function selectAll(selectedState, selected) {
+ const newSelectedState = _.reduce(Object.keys(selectedState), (result, item) => {
+ result[item] = selected;
+ return result;
+ }, {});
+
+ return {
+ allSelected: selected,
+ allUnselected: !selected,
+ lastToggled: null,
+ selectedState: newSelectedState
+ };
+}
+
+export default selectAll;
diff --git a/frontend/src/Utilities/Table/toggleSelected.js b/frontend/src/Utilities/Table/toggleSelected.js
new file mode 100644
index 000000000..ec8870b0b
--- /dev/null
+++ b/frontend/src/Utilities/Table/toggleSelected.js
@@ -0,0 +1,30 @@
+import areAllSelected from './areAllSelected';
+import getToggledRange from './getToggledRange';
+
+function toggleSelected(selectedState, items, id, selected, shiftKey) {
+ const lastToggled = selectedState.lastToggled;
+ const nextSelectedState = {
+ ...selectedState.selectedState,
+ [id]: selected
+ };
+
+ if (selected == null) {
+ delete nextSelectedState[id];
+ }
+
+ if (shiftKey && lastToggled) {
+ const { lower, upper } = getToggledRange(items, id, lastToggled);
+
+ for (let i = lower; i < upper; i++) {
+ nextSelectedState[items[i].id] = selected;
+ }
+ }
+
+ return {
+ ...areAllSelected(nextSelectedState),
+ lastToggled: id,
+ selectedState: nextSelectedState
+ };
+}
+
+export default toggleSelected;
diff --git a/frontend/src/Utilities/browser.js b/frontend/src/Utilities/browser.js
new file mode 100644
index 000000000..ff896e801
--- /dev/null
+++ b/frontend/src/Utilities/browser.js
@@ -0,0 +1,16 @@
+import MobileDetect from 'mobile-detect';
+
+const mobileDetect = new MobileDetect(window.navigator.userAgent);
+
+export function isMobile() {
+
+ return mobileDetect.mobile() != null;
+}
+
+export function isIOS() {
+ return mobileDetect.is('iOS');
+}
+
+export function isFirefox() {
+ return window.navigator.userAgent.toLowerCase().indexOf('firefox/') >= 0;
+}
diff --git a/frontend/src/Utilities/createAjaxRequest.js b/frontend/src/Utilities/createAjaxRequest.js
new file mode 100644
index 000000000..d4836cadc
--- /dev/null
+++ b/frontend/src/Utilities/createAjaxRequest.js
@@ -0,0 +1,63 @@
+import $ from 'jquery';
+
+const absUrlRegex = /^(https?:)?\/\//i;
+const apiRoot = window.Lidarr.apiRoot;
+
+function isRelative(ajaxOptions) {
+ return !absUrlRegex.test(ajaxOptions.url);
+}
+
+function addRootUrl(ajaxOptions) {
+ ajaxOptions.url = apiRoot + ajaxOptions.url;
+}
+
+function addApiKey(ajaxOptions) {
+ ajaxOptions.headers = ajaxOptions.headers || {};
+ ajaxOptions.headers['X-Api-Key'] = window.Lidarr.apiKey;
+}
+
+function addContentType(ajaxOptions) {
+ if (
+ ajaxOptions.contentType == null &&
+ ajaxOptions.dataType === 'json' &&
+ (ajaxOptions.method === 'PUT' || ajaxOptions.method === 'POST' || ajaxOptions.method === 'DELETE')) {
+ ajaxOptions.contentType = 'application/json';
+ }
+}
+
+export default function createAjaxRequest(originalAjaxOptions) {
+ const requestXHR = new window.XMLHttpRequest();
+ let aborted = false;
+ let complete = false;
+
+ function abortRequest() {
+ if (!complete) {
+ aborted = true;
+ requestXHR.abort();
+ }
+ }
+
+ const ajaxOptions = { ...originalAjaxOptions };
+
+ if (isRelative(ajaxOptions)) {
+ addRootUrl(ajaxOptions);
+ addApiKey(ajaxOptions);
+ addContentType(ajaxOptions);
+ }
+
+ const request = $.ajax({
+ xhr: () => requestXHR,
+ ...ajaxOptions
+ }).then(null, (xhr, textStatus, errorThrown) => {
+ xhr.aborted = aborted;
+
+ return $.Deferred().reject(xhr, textStatus, errorThrown).promise();
+ }).always(() => {
+ complete = true;
+ });
+
+ return {
+ request,
+ abortRequest
+ };
+}
diff --git a/frontend/src/Utilities/getPathWithUrlBase.js b/frontend/src/Utilities/getPathWithUrlBase.js
new file mode 100644
index 000000000..292e98dba
--- /dev/null
+++ b/frontend/src/Utilities/getPathWithUrlBase.js
@@ -0,0 +1,3 @@
+export default function getPathWithUrlBase(path) {
+ return `${window.Lidarr.urlBase}${path}`;
+}
diff --git a/frontend/src/Utilities/getUniqueElementId.js b/frontend/src/Utilities/getUniqueElementId.js
new file mode 100644
index 000000000..dae5150b7
--- /dev/null
+++ b/frontend/src/Utilities/getUniqueElementId.js
@@ -0,0 +1,7 @@
+let i = 0;
+
+// returns a HTML 4.0 compliant element IDs (http://stackoverflow.com/a/79022)
+
+export default function getUniqueElementId() {
+ return `id-${i++}`;
+}
diff --git a/frontend/src/Utilities/pagePopulator.js b/frontend/src/Utilities/pagePopulator.js
new file mode 100644
index 000000000..f58dbe803
--- /dev/null
+++ b/frontend/src/Utilities/pagePopulator.js
@@ -0,0 +1,28 @@
+let currentPopulator = null;
+let currentReasons = [];
+
+export function registerPagePopulator(populator, reasons = []) {
+ currentPopulator = populator;
+ currentReasons = reasons;
+}
+
+export function unregisterPagePopulator(populator) {
+ if (currentPopulator === populator) {
+ currentPopulator = null;
+ currentReasons = [];
+ }
+}
+
+export function repopulatePage(reason) {
+ if (!currentPopulator) {
+ return;
+ }
+
+ if (!reason) {
+ currentPopulator();
+ }
+
+ if (reason && currentReasons.includes(reason)) {
+ currentPopulator();
+ }
+}
diff --git a/frontend/src/Utilities/pages.js b/frontend/src/Utilities/pages.js
new file mode 100644
index 000000000..1355442d9
--- /dev/null
+++ b/frontend/src/Utilities/pages.js
@@ -0,0 +1,9 @@
+const pages = {
+ FIRST: 'first',
+ PREVIOUS: 'previous',
+ NEXT: 'next',
+ LAST: 'last',
+ EXACT: 'exact'
+};
+
+export default pages;
diff --git a/frontend/src/Utilities/requestAction.js b/frontend/src/Utilities/requestAction.js
new file mode 100644
index 000000000..ed69cf5ad
--- /dev/null
+++ b/frontend/src/Utilities/requestAction.js
@@ -0,0 +1,41 @@
+import $ from 'jquery';
+import _ from 'lodash';
+import createAjaxRequest from './createAjaxRequest';
+
+function flattenProviderData(providerData) {
+ return _.reduce(Object.keys(providerData), (result, key) => {
+ const property = providerData[key];
+
+ if (key === 'fields') {
+ result[key] = property;
+ } else {
+ result[key] = property.value;
+ }
+
+ return result;
+ }, {});
+}
+
+function requestAction(payload) {
+ const {
+ provider,
+ action,
+ providerData,
+ queryParams
+ } = payload;
+
+ const ajaxOptions = {
+ url: `/${provider}/action/${action}`,
+ contentType: 'application/json',
+ method: 'POST',
+ data: JSON.stringify(flattenProviderData(providerData))
+ };
+
+ if (queryParams) {
+ ajaxOptions.url += `?${$.param(queryParams, true)}`;
+ }
+
+ return createAjaxRequest(ajaxOptions).request;
+}
+
+export default requestAction;
diff --git a/frontend/src/Utilities/scrollLock.js b/frontend/src/Utilities/scrollLock.js
new file mode 100644
index 000000000..cff50a34b
--- /dev/null
+++ b/frontend/src/Utilities/scrollLock.js
@@ -0,0 +1,13 @@
+// Allow iOS devices to disable scrolling of the body/virtual table
+// when a modal is open. This will prevent focusing an input in a
+// modal causing the modal to close due to scrolling.
+
+let scrollLock = false;
+
+export function isLocked() {
+ return scrollLock;
+}
+
+export function setScrollLock(locked) {
+ scrollLock = locked;
+}
diff --git a/frontend/src/Utilities/sectionTypes.js b/frontend/src/Utilities/sectionTypes.js
new file mode 100644
index 000000000..5479b32b9
--- /dev/null
+++ b/frontend/src/Utilities/sectionTypes.js
@@ -0,0 +1,6 @@
+const sectionTypes = {
+ COLLECTION: 'collection',
+ MODEL: 'model'
+};
+
+export default sectionTypes;
diff --git a/frontend/src/Utilities/serverSideCollectionHandlers.js b/frontend/src/Utilities/serverSideCollectionHandlers.js
new file mode 100644
index 000000000..03fa39c00
--- /dev/null
+++ b/frontend/src/Utilities/serverSideCollectionHandlers.js
@@ -0,0 +1,12 @@
+const serverSideCollectionHandlers = {
+ FETCH: 'fetch',
+ FIRST_PAGE: 'firstPage',
+ PREVIOUS_PAGE: 'previousPage',
+ NEXT_PAGE: 'nextPage',
+ LAST_PAGE: 'lastPage',
+ EXACT_PAGE: 'exactPage',
+ SORT: 'sort',
+ FILTER: 'filter'
+};
+
+export default serverSideCollectionHandlers;
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js
new file mode 100644
index 000000000..6710118b1
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js
@@ -0,0 +1,296 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Alert from 'Components/Alert';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
+import TablePager from 'Components/Table/TablePager';
+import { align, icons, kinds } from 'Helpers/Props';
+import getFilterValue from 'Utilities/Filter/getFilterValue';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import translate from 'Utilities/String/translate';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import CutoffUnmetRowConnector from './CutoffUnmetRowConnector';
+
+function getMonitoredValue(props) {
+ const {
+ filters,
+ selectedFilterKey
+ } = props;
+
+ return getFilterValue(filters, selectedFilterKey, 'monitored', false);
+}
+
+class CutoffUnmet extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ isConfirmSearchAllCutoffUnmetModalOpen: false,
+ isInteractiveImportModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ this.setState((state) => {
+ return removeOldSelectedState(state, prevProps.items);
+ });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ };
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ };
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ };
+
+ onSearchSelectedPress = () => {
+ const selected = this.getSelectedIds();
+
+ this.props.onSearchSelectedPress(selected);
+ };
+
+ onToggleSelectedPress = () => {
+ const albumIds = this.getSelectedIds();
+
+ this.props.batchToggleCutoffUnmetAlbums({
+ albumIds,
+ monitored: !getMonitoredValue(this.props)
+ });
+ };
+
+ onSearchAllCutoffUnmetPress = () => {
+ this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: true });
+ };
+
+ onSearchAllCutoffUnmetConfirmed = () => {
+ this.props.onSearchAllCutoffUnmetPress();
+ this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false });
+ };
+
+ onConfirmSearchAllCutoffUnmetModalClose = () => {
+ this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ isArtistFetching,
+ isArtistPopulated,
+ selectedFilterKey,
+ filters,
+ columns,
+ totalRecords,
+ isSearchingForCutoffUnmetAlbums,
+ isSaving,
+ onFilterSelect,
+ ...otherProps
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ isConfirmSearchAllCutoffUnmetModalOpen
+ } = this.state;
+
+ const isAllPopulated = isPopulated && isArtistPopulated;
+ const isAnyFetching = isFetching || isArtistFetching;
+
+ const itemsSelected = !!this.getSelectedIds().length;
+ const isShowingMonitored = getMonitoredValue(this.props);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isAnyFetching && !isAllPopulated &&
+
+ }
+
+ {
+ !isAnyFetching && error &&
+
+ Error fetching cutoff unmet
+
+ }
+
+ {
+ isAllPopulated && !error && !items.length &&
+
+ {translate('NoCutoffUnmetItems')}
+
+ }
+
+ {
+ isAllPopulated && !error && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+ {translate('SearchForAllCutoffUnmetAlbumsConfirmationCount', { totalRecords })}
+
+
+ {translate('MassSearchCancelWarning')}
+
+
+ }
+ confirmLabel={translate('Search')}
+ onConfirm={this.onSearchAllCutoffUnmetConfirmed}
+ onCancel={this.onConfirmSearchAllCutoffUnmetModalClose}
+ />
+
+ }
+
+
+ );
+ }
+}
+
+CutoffUnmet.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isArtistFetching: PropTypes.bool.isRequired,
+ isArtistPopulated: PropTypes.bool.isRequired,
+ selectedFilterKey: PropTypes.string.isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ totalRecords: PropTypes.number,
+ isSearchingForCutoffUnmetAlbums: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onSearchSelectedPress: PropTypes.func.isRequired,
+ batchToggleCutoffUnmetAlbums: PropTypes.func.isRequired,
+ onSearchAllCutoffUnmetPress: PropTypes.func.isRequired
+};
+
+export default CutoffUnmet;
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js
new file mode 100644
index 000000000..1dd9870d1
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js
@@ -0,0 +1,191 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import * as commandNames from 'Commands/commandNames';
+import withCurrentPage from 'Components/withCurrentPage';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
+import { clearTrackFiles, fetchTrackFiles } from 'Store/Actions/trackFileActions';
+import * as wantedActions from 'Store/Actions/wantedActions';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import CutoffUnmet from './CutoffUnmet';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.wanted.cutoffUnmet,
+ (state) => state.artist,
+ createCommandExecutingSelector(commandNames.CUTOFF_UNMET_ALBUM_SEARCH),
+ (cutoffUnmet, artist, isSearchingForCutoffUnmetAlbums) => {
+
+ return {
+ isArtistFetching: artist.isFetching,
+ isArtistPopulated: artist.isPopulated,
+ isSearchingForCutoffUnmetAlbums,
+ isSaving: cutoffUnmet.items.filter((m) => m.isSaving).length > 1,
+ ...cutoffUnmet
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...wantedActions,
+ executeCommand,
+ fetchQueueDetails,
+ clearQueueDetails,
+ fetchTrackFiles,
+ clearTrackFiles
+};
+
+class CutoffUnmetConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ useCurrentPage,
+ fetchCutoffUnmet,
+ gotoCutoffUnmetFirstPage
+ } = this.props;
+
+ registerPagePopulator(this.repopulate, ['trackFileUpdated', 'trackFileDeleted']);
+
+ if (useCurrentPage) {
+ fetchCutoffUnmet();
+ } else {
+ gotoCutoffUnmetFirstPage();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ const albumIds = selectUniqueIds(this.props.items, 'id');
+ const trackFileIds = selectUniqueIds(this.props.items, 'trackFileId');
+
+ this.props.fetchQueueDetails({ albumIds });
+
+ if (trackFileIds.length) {
+ this.props.fetchTrackFiles({ trackFileIds });
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ this.props.clearCutoffUnmet();
+ this.props.clearQueueDetails();
+ this.props.clearTrackFiles();
+ }
+
+ //
+ // Control
+
+ repopulate = () => {
+ this.props.fetchCutoffUnmet();
+ };
+
+ //
+ // Listeners
+
+ onFirstPagePress = () => {
+ this.props.gotoCutoffUnmetFirstPage();
+ };
+
+ onPreviousPagePress = () => {
+ this.props.gotoCutoffUnmetPreviousPage();
+ };
+
+ onNextPagePress = () => {
+ this.props.gotoCutoffUnmetNextPage();
+ };
+
+ onLastPagePress = () => {
+ this.props.gotoCutoffUnmetLastPage();
+ };
+
+ onPageSelect = (page) => {
+ this.props.gotoCutoffUnmetPage({ page });
+ };
+
+ onSortPress = (sortKey) => {
+ this.props.setCutoffUnmetSort({ sortKey });
+ };
+
+ onFilterSelect = (selectedFilterKey) => {
+ this.props.setCutoffUnmetFilter({ selectedFilterKey });
+ };
+
+ onTableOptionChange = (payload) => {
+ this.props.setCutoffUnmetTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoCutoffUnmetFirstPage();
+ }
+ };
+
+ onSearchSelectedPress = (selected) => {
+ this.props.executeCommand({
+ name: commandNames.ALBUM_SEARCH,
+ albumIds: selected,
+ commandFinished: this.repopulate
+ });
+ };
+
+ onSearchAllCutoffUnmetPress = () => {
+ this.props.executeCommand({
+ name: commandNames.CUTOFF_UNMET_ALBUM_SEARCH,
+ commandFinished: this.repopulate
+ });
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+CutoffUnmetConnector.propTypes = {
+ useCurrentPage: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchCutoffUnmet: PropTypes.func.isRequired,
+ gotoCutoffUnmetFirstPage: PropTypes.func.isRequired,
+ gotoCutoffUnmetPreviousPage: PropTypes.func.isRequired,
+ gotoCutoffUnmetNextPage: PropTypes.func.isRequired,
+ gotoCutoffUnmetLastPage: PropTypes.func.isRequired,
+ gotoCutoffUnmetPage: PropTypes.func.isRequired,
+ setCutoffUnmetSort: PropTypes.func.isRequired,
+ setCutoffUnmetFilter: PropTypes.func.isRequired,
+ setCutoffUnmetTableOption: PropTypes.func.isRequired,
+ clearCutoffUnmet: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
+ fetchQueueDetails: PropTypes.func.isRequired,
+ clearQueueDetails: PropTypes.func.isRequired,
+ fetchTrackFiles: PropTypes.func.isRequired,
+ clearTrackFiles: PropTypes.func.isRequired
+};
+
+export default withCurrentPage(
+ connect(createMapStateToProps, mapDispatchToProps)(CutoffUnmetConnector)
+);
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css
new file mode 100644
index 000000000..106842b2b
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css
@@ -0,0 +1,6 @@
+.episode,
+.status {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css.d.ts b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css.d.ts
new file mode 100644
index 000000000..6afcbf6d6
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'episode': string;
+ 'status': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js
new file mode 100644
index 000000000..452e2947a
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js
@@ -0,0 +1,152 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import albumEntities from 'Album/albumEntities';
+import AlbumSearchCellConnector from 'Album/AlbumSearchCellConnector';
+import AlbumTitleLink from 'Album/AlbumTitleLink';
+import EpisodeStatusConnector from 'Album/EpisodeStatusConnector';
+import ArtistNameLink from 'Artist/ArtistNameLink';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import TableRow from 'Components/Table/TableRow';
+import styles from './CutoffUnmetRow.css';
+
+function CutoffUnmetRow(props) {
+ const {
+ id,
+ trackFileId,
+ artist,
+ releaseDate,
+ foreignAlbumId,
+ albumType,
+ title,
+ lastSearchTime,
+ disambiguation,
+ isSelected,
+ columns,
+ onSelectedChange
+ } = props;
+
+ if (!artist) {
+ return null;
+ }
+
+ return (
+
+
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'artists.sortName') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'albums.title') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'albumType') {
+ return (
+
+ {albumType}
+
+ );
+ }
+
+ if (name === 'releaseDate') {
+ return (
+
+ );
+ }
+
+ if (name === 'albums.lastSearchTime') {
+ return (
+
+ );
+ }
+
+ if (name === 'status') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+ );
+}
+
+CutoffUnmetRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ trackFileId: PropTypes.number,
+ artist: PropTypes.object.isRequired,
+ releaseDate: PropTypes.string.isRequired,
+ foreignAlbumId: PropTypes.string.isRequired,
+ albumType: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ lastSearchTime: PropTypes.string,
+ disambiguation: PropTypes.string,
+ isSelected: PropTypes.bool,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+export default CutoffUnmetRow;
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRowConnector.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRowConnector.js
new file mode 100644
index 000000000..625055c57
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRowConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import CutoffUnmetRow from './CutoffUnmetRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ (artist) => {
+ return {
+ artist
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(CutoffUnmetRow);
diff --git a/frontend/src/Wanted/Missing/Missing.js b/frontend/src/Wanted/Missing/Missing.js
new file mode 100644
index 000000000..52bfbc30d
--- /dev/null
+++ b/frontend/src/Wanted/Missing/Missing.js
@@ -0,0 +1,319 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Alert from 'Components/Alert';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
+import TablePager from 'Components/Table/TablePager';
+import { align, icons, kinds } from 'Helpers/Props';
+import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
+import getFilterValue from 'Utilities/Filter/getFilterValue';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import translate from 'Utilities/String/translate';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import MissingRowConnector from './MissingRowConnector';
+
+function getMonitoredValue(props) {
+ const {
+ filters,
+ selectedFilterKey
+ } = props;
+
+ return getFilterValue(filters, selectedFilterKey, 'monitored', false);
+}
+
+class Missing extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ isConfirmSearchAllMissingModalOpen: false,
+ isInteractiveImportModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ this.setState((state) => {
+ return removeOldSelectedState(state, prevProps.items);
+ });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ };
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ };
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ };
+
+ onSearchSelectedPress = () => {
+ const selected = this.getSelectedIds();
+
+ this.props.onSearchSelectedPress(selected);
+ };
+
+ onToggleSelectedPress = () => {
+ const albumIds = this.getSelectedIds();
+
+ this.props.batchToggleMissingAlbums({
+ albumIds,
+ monitored: !getMonitoredValue(this.props)
+ });
+ };
+
+ onSearchAllMissingPress = () => {
+ this.setState({ isConfirmSearchAllMissingModalOpen: true });
+ };
+
+ onSearchAllMissingConfirmed = () => {
+ this.props.onSearchAllMissingPress();
+ this.setState({ isConfirmSearchAllMissingModalOpen: false });
+ };
+
+ onConfirmSearchAllMissingModalClose = () => {
+ this.setState({ isConfirmSearchAllMissingModalOpen: false });
+ };
+
+ onInteractiveImportPress = () => {
+ this.setState({ isInteractiveImportModalOpen: true });
+ };
+
+ onInteractiveImportModalClose = () => {
+ this.setState({ isInteractiveImportModalOpen: false });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ isArtistFetching,
+ isArtistPopulated,
+ selectedFilterKey,
+ filters,
+ columns,
+ totalRecords,
+ isSearchingForMissingAlbums,
+ isSaving,
+ onFilterSelect,
+ ...otherProps
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ isConfirmSearchAllMissingModalOpen,
+ isInteractiveImportModalOpen
+ } = this.state;
+
+ const isAllPopulated = isPopulated && isArtistPopulated;
+ const isAnyFetching = isFetching || isArtistFetching;
+
+ const itemsSelected = !!this.getSelectedIds().length;
+ const isShowingMonitored = getMonitoredValue(this.props);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isAnyFetching && !isAllPopulated &&
+
+ }
+
+ {
+ !isAnyFetching && error &&
+
+ Error fetching missing items
+
+ }
+
+ {
+ isAllPopulated && !error && !items.length &&
+
+ {translate('NoMissingItems')}
+
+ }
+
+ {
+ isAllPopulated && !error && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+ {translate('SearchForAllMissingAlbumsConfirmationCount', { totalRecords })}
+
+
+ {translate('MassSearchCancelWarning')}
+
+
+ }
+ confirmLabel={translate('Search')}
+ onConfirm={this.onSearchAllMissingConfirmed}
+ onCancel={this.onConfirmSearchAllMissingModalClose}
+ />
+
+ }
+
+
+
+
+ );
+ }
+}
+
+Missing.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isArtistFetching: PropTypes.bool.isRequired,
+ isArtistPopulated: PropTypes.bool.isRequired,
+ selectedFilterKey: PropTypes.string.isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ totalRecords: PropTypes.number,
+ isSearchingForMissingAlbums: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onSearchSelectedPress: PropTypes.func.isRequired,
+ batchToggleMissingAlbums: PropTypes.func.isRequired,
+ onSearchAllMissingPress: PropTypes.func.isRequired
+};
+
+export default Missing;
diff --git a/frontend/src/Wanted/Missing/MissingConnector.js b/frontend/src/Wanted/Missing/MissingConnector.js
new file mode 100644
index 000000000..008f1a149
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingConnector.js
@@ -0,0 +1,179 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import * as commandNames from 'Commands/commandNames';
+import withCurrentPage from 'Components/withCurrentPage';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
+import * as wantedActions from 'Store/Actions/wantedActions';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import Missing from './Missing';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.wanted.missing,
+ (state) => state.artist,
+ createCommandExecutingSelector(commandNames.MISSING_ALBUM_SEARCH),
+ (missing, artist, isSearchingForMissingAlbums) => {
+
+ return {
+ isArtistFetching: artist.isFetching,
+ isArtistPopulated: artist.isPopulated,
+ isSearchingForMissingAlbums,
+ isSaving: missing.items.filter((m) => m.isSaving).length > 1,
+ ...missing
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...wantedActions,
+ executeCommand,
+ fetchQueueDetails,
+ clearQueueDetails
+};
+
+class MissingConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ useCurrentPage,
+ fetchMissing,
+ gotoMissingFirstPage
+ } = this.props;
+
+ registerPagePopulator(this.repopulate, ['trackFileUpdated', 'trackFileDeleted']);
+
+ if (useCurrentPage) {
+ fetchMissing();
+ } else {
+ gotoMissingFirstPage();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ const albumIds = selectUniqueIds(this.props.items, 'id');
+ this.props.fetchQueueDetails({ albumIds });
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ this.props.clearMissing();
+ this.props.clearQueueDetails();
+ }
+
+ //
+ // Control
+
+ repopulate = () => {
+ this.props.fetchMissing();
+ };
+
+ //
+ // Listeners
+
+ onFirstPagePress = () => {
+ this.props.gotoMissingFirstPage();
+ };
+
+ onPreviousPagePress = () => {
+ this.props.gotoMissingPreviousPage();
+ };
+
+ onNextPagePress = () => {
+ this.props.gotoMissingNextPage();
+ };
+
+ onLastPagePress = () => {
+ this.props.gotoMissingLastPage();
+ };
+
+ onPageSelect = (page) => {
+ this.props.gotoMissingPage({ page });
+ };
+
+ onSortPress = (sortKey) => {
+ this.props.setMissingSort({ sortKey });
+ };
+
+ onFilterSelect = (selectedFilterKey) => {
+ this.props.setMissingFilter({ selectedFilterKey });
+ };
+
+ onTableOptionChange = (payload) => {
+ this.props.setMissingTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoMissingFirstPage();
+ }
+ };
+
+ onSearchSelectedPress = (selected) => {
+ this.props.executeCommand({
+ name: commandNames.ALBUM_SEARCH,
+ albumIds: selected,
+ commandFinished: this.repopulate
+ });
+ };
+
+ onSearchAllMissingPress = () => {
+ this.props.executeCommand({
+ name: commandNames.MISSING_ALBUM_SEARCH,
+ commandFinished: this.repopulate
+ });
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MissingConnector.propTypes = {
+ useCurrentPage: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchMissing: PropTypes.func.isRequired,
+ gotoMissingFirstPage: PropTypes.func.isRequired,
+ gotoMissingPreviousPage: PropTypes.func.isRequired,
+ gotoMissingNextPage: PropTypes.func.isRequired,
+ gotoMissingLastPage: PropTypes.func.isRequired,
+ gotoMissingPage: PropTypes.func.isRequired,
+ setMissingSort: PropTypes.func.isRequired,
+ setMissingFilter: PropTypes.func.isRequired,
+ setMissingTableOption: PropTypes.func.isRequired,
+ clearMissing: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
+ fetchQueueDetails: PropTypes.func.isRequired,
+ clearQueueDetails: PropTypes.func.isRequired
+};
+
+export default withCurrentPage(
+ connect(createMapStateToProps, mapDispatchToProps)(MissingConnector)
+);
diff --git a/frontend/src/Wanted/Missing/MissingRow.css b/frontend/src/Wanted/Missing/MissingRow.css
new file mode 100644
index 000000000..1794c2530
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingRow.css
@@ -0,0 +1,5 @@
+.status {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
diff --git a/frontend/src/Wanted/Missing/MissingRow.js b/frontend/src/Wanted/Missing/MissingRow.js
new file mode 100644
index 000000000..6c0b5a0c6
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingRow.js
@@ -0,0 +1,133 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import albumEntities from 'Album/albumEntities';
+import AlbumSearchCellConnector from 'Album/AlbumSearchCellConnector';
+import AlbumTitleLink from 'Album/AlbumTitleLink';
+import ArtistNameLink from 'Artist/ArtistNameLink';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import TableRow from 'Components/Table/TableRow';
+
+function MissingRow(props) {
+ const {
+ id,
+ artist,
+ releaseDate,
+ albumType,
+ foreignAlbumId,
+ title,
+ lastSearchTime,
+ disambiguation,
+ isSelected,
+ columns,
+ onSelectedChange
+ } = props;
+
+ if (!artist) {
+ return null;
+ }
+
+ return (
+
+
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'artists.sortName') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'albums.title') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'albumType') {
+ return (
+
+ {albumType}
+
+ );
+ }
+
+ if (name === 'releaseDate') {
+ return (
+
+ );
+ }
+
+ if (name === 'albums.lastSearchTime') {
+ return (
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+ );
+}
+
+MissingRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ artist: PropTypes.object.isRequired,
+ releaseDate: PropTypes.string.isRequired,
+ foreignAlbumId: PropTypes.string.isRequired,
+ albumType: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ lastSearchTime: PropTypes.string,
+ disambiguation: PropTypes.string,
+ isSelected: PropTypes.bool,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+export default MissingRow;
diff --git a/frontend/src/Wanted/Missing/MissingRowConnector.js b/frontend/src/Wanted/Missing/MissingRowConnector.js
new file mode 100644
index 000000000..f0a30d9cd
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingRowConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import MissingRow from './MissingRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ (artist) => {
+ return {
+ artist
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(MissingRow);
diff --git a/frontend/src/bootstrap.tsx b/frontend/src/bootstrap.tsx
new file mode 100644
index 000000000..9ecf27e0e
--- /dev/null
+++ b/frontend/src/bootstrap.tsx
@@ -0,0 +1,16 @@
+import { createBrowserHistory } from 'history';
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+import createAppStore from 'Store/createAppStore';
+import App from './App/App';
+
+import 'Diag/ConsoleApi';
+
+export async function bootstrap() {
+ const history = createBrowserHistory();
+ const store = createAppStore(history);
+ const container = document.getElementById('root');
+
+ const root = createRoot(container!); // createRoot(container!) if you use TypeScript
+ root.render( );
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 000000000..2a94ba941
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,15 @@
+html,
+body {
+ height: 100%; /* needed for proper layout */
+}
+
+body {
+ overflow: hidden;
+ background-color: var(--pageBackground);
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ body {
+ overflow-y: auto;
+ }
+}
diff --git a/frontend/src/index.css.d.ts b/frontend/src/index.css.d.ts
new file mode 100644
index 000000000..132b232e8
--- /dev/null
+++ b/frontend/src/index.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/index.ejs b/frontend/src/index.ejs
new file mode 100644
index 000000000..a893149d5
--- /dev/null
+++ b/frontend/src/index.ejs
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <% for (key in htmlWebpackPlugin.files.js) { %><% } %>
+ <% for (key in htmlWebpackPlugin.files.css) { %> <% } %>
+
+ Lidarr
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/index.ts b/frontend/src/index.ts
new file mode 100644
index 000000000..37e780919
--- /dev/null
+++ b/frontend/src/index.ts
@@ -0,0 +1,45 @@
+import './polyfills';
+import 'Styles/globals.css';
+import './index.css';
+
+const initializeUrl = `${
+ window.Lidarr.urlBase
+}/initialize.json?t=${Date.now()}`;
+const response = await fetch(initializeUrl);
+
+window.Lidarr = await response.json();
+
+/* eslint-disable no-undef, @typescript-eslint/ban-ts-comment */
+// @ts-ignore 2304
+__webpack_public_path__ = `${window.Lidarr.urlBase}/`;
+/* eslint-enable no-undef, @typescript-eslint/ban-ts-comment */
+
+const error = console.error;
+
+// Monkey patch console.error to filter out some warnings from React
+// TODO: Remove this after the great TypeScript migration
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function logError(...parameters: any[]) {
+ const filter = parameters.find((parameter) => {
+ return (
+ typeof parameter === 'string' &&
+ (parameter.includes(
+ 'Support for defaultProps will be removed from function components in a future major release'
+ ) ||
+ parameter.includes(
+ 'findDOMNode is deprecated and will be removed in the next major release'
+ ))
+ );
+ });
+
+ if (!filter) {
+ error(...parameters);
+ }
+}
+
+console.error = logError;
+
+const { bootstrap } = await import('./bootstrap');
+
+await bootstrap();
diff --git a/frontend/src/login.html b/frontend/src/login.html
new file mode 100644
index 000000000..24d086959
--- /dev/null
+++ b/frontend/src/login.html
@@ -0,0 +1,348 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Login - Lidarr
+
+
+
+
+
+
+
+
+
+
+
+
SIGN IN TO CONTINUE
+
+
+
+
+
+
+ ©
+
+ -
+ Lidarr
+
+
+
+
+
+
+
diff --git a/src/UI/oauth.html b/frontend/src/oauth.html
similarity index 78%
rename from src/UI/oauth.html
rename to frontend/src/oauth.html
index 5767c06ff..16a34dbf3 100644
--- a/src/UI/oauth.html
+++ b/frontend/src/oauth.html
@@ -2,12 +2,12 @@
- oauth landing page
-
Shouldn't see this
-
\ No newline at end of file
+