New: Mass Editor is now part of artists list

(cherry picked from commit a731d24e23b83484da1376d331b2ce998e216690)
pull/4254/head
Mark McDowall 1 year ago committed by Bogdan
parent 84d5f2bcee
commit fae94f4b8e

@ -146,7 +146,7 @@ class AlbumStudioFooter extends Component {
<div>
<div className={styles.label}>
{translate('CountArtistsSelected', { selectedCount })}
{translate('CountArtistsSelected', { count: selectedCount })}
</div>
<SpinnerButton

@ -57,7 +57,6 @@ const initialState = {
interface SelectProviderOptions<T extends ModelBase> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children: any;
isSelectMode: boolean;
items: Array<T>;
}
@ -97,7 +96,7 @@ function selectReducer(state: SelectState, action: SelectAction): SelectState {
};
}
case SelectActionType.ToggleSelected: {
var result = {
const result = {
items,
...toggleSelected(
state,
@ -129,7 +128,7 @@ function selectReducer(state: SelectState, action: SelectAction): SelectState {
export function SelectProvider<T extends ModelBase>(
props: SelectProviderOptions<T>
) {
const { isSelectMode, items } = props;
const { items } = props;
const selectedState = getSelectedState(items, {});
const [state, dispatch] = React.useReducer(selectReducer, {
@ -142,12 +141,6 @@ export function SelectProvider<T extends ModelBase>(
const value: [SelectState, Dispatch] = [state, dispatch];
useEffect(() => {
if (!isSelectMode) {
dispatch({ type: SelectActionType.Reset });
}
}, [isSelectMode]);
useEffect(() => {
dispatch({ type: SelectActionType.UpdateItems, items });
}, [items]);

@ -21,7 +21,7 @@ function OrganizeArtistModalContent(props) {
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('OrganizeArtist')}
{translate('OrganizeSelectedArtists')}
</ModalHeader>
<ModalBody>

@ -13,6 +13,7 @@
.contentBody {
composes: contentBody from '~Components/Page/PageContentBody.css';
position: relative;
display: flex;
flex-direction: column;
}

@ -39,6 +39,10 @@ import ArtistIndexOverviewOptionsModal from './Overview/Options/ArtistIndexOverv
import ArtistIndexPosters from './Posters/ArtistIndexPosters';
import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal';
import ArtistIndexSelectAllButton from './Select/ArtistIndexSelectAllButton';
import ArtistIndexSelectAllMenuItem from './Select/ArtistIndexSelectAllMenuItem';
import ArtistIndexSelectFooter from './Select/ArtistIndexSelectFooter';
import ArtistIndexSelectModeButton from './Select/ArtistIndexSelectModeButton';
import ArtistIndexSelectModeMenuItem from './Select/ArtistIndexSelectModeMenuItem';
import ArtistIndexTable from './Table/ArtistIndexTable';
import ArtistIndexTableOptions from './Table/ArtistIndexTableOptions';
import styles from './ArtistIndex.css';
@ -209,7 +213,7 @@ const ArtistIndex = withScrollPosition((props: ArtistIndexProps) => {
const hasNoArtist = !totalItems;
return (
<SelectProvider isSelectMode={isSelectMode} items={items}>
<SelectProvider items={items}>
<PageContent>
<PageToolbar>
<PageToolbarSection>
@ -232,13 +236,19 @@ const ArtistIndex = withScrollPosition((props: ArtistIndexProps) => {
<PageToolbarSeparator />
<PageToolbarButton
<ArtistIndexSelectModeButton
label={isSelectMode ? 'Stop Selecting' : 'Select Artists'}
iconName={isSelectMode ? icons.ARTIST_ENDED : icons.CHECK}
isSelectMode={isSelectMode}
overflowComponent={ArtistIndexSelectModeMenuItem}
onPress={onSelectModePress}
/>
{isSelectMode ? <ArtistIndexSelectAllButton /> : null}
<ArtistIndexSelectAllButton
label="SelectAll"
isSelectMode={isSelectMode}
overflowComponent={ArtistIndexSelectAllMenuItem}
/>
</PageToolbarSection>
<PageToolbarSection
@ -325,7 +335,6 @@ const ArtistIndex = withScrollPosition((props: ArtistIndexProps) => {
<NoArtist totalItems={totalItems} />
) : null}
</PageContentBody>
{isLoaded && !!jumpBarItems.order.length ? (
<PageJumpBar
items={jumpBarItems}
@ -333,6 +342,9 @@ const ArtistIndex = withScrollPosition((props: ArtistIndexProps) => {
/>
) : null}
</div>
{isSelectMode ? <ArtistIndexSelectFooter /> : null}
{view === 'posters' ? (
<ArtistIndexPosterOptionsModal
isOpen={isOptionsModalOpen}

@ -3,7 +3,14 @@ import { SelectActionType, useSelect } from 'App/SelectContext';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import { icons } from 'Helpers/Props';
function ArtistIndexSelectAllButton() {
interface ArtistIndexSelectAllButtonProps {
label: string;
isSelectMode: boolean;
overflowComponent: React.FunctionComponent;
}
function ArtistIndexSelectAllButton(props: ArtistIndexSelectAllButtonProps) {
const { isSelectMode } = props;
const [selectState, selectDispatch] = useSelect();
const { allSelected, allUnselected } = selectState;
@ -23,13 +30,13 @@ function ArtistIndexSelectAllButton() {
});
}, [allSelected, selectDispatch]);
return (
return isSelectMode ? (
<PageToolbarButton
label={allSelected ? 'Unselect All' : 'Select All'}
iconName={icon}
onPress={onPress}
/>
);
) : null;
}
export default ArtistIndexSelectAllButton;

@ -0,0 +1,43 @@
import React, { useCallback } from 'react';
import { SelectActionType, useSelect } from 'App/SelectContext';
import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem';
import { icons } from 'Helpers/Props';
interface ArtistIndexSelectAllMenuItemProps {
label: string;
isSelectMode: boolean;
}
function ArtistIndexSelectAllMenuItem(
props: ArtistIndexSelectAllMenuItemProps
) {
const { isSelectMode } = props;
const [selectState, selectDispatch] = useSelect();
const { allSelected, allUnselected } = selectState;
let iconName = icons.SQUARE_MINUS;
if (allSelected) {
iconName = icons.CHECK_SQUARE;
} else if (allUnselected) {
iconName = icons.SQUARE;
}
const onPressWrapper = useCallback(() => {
selectDispatch({
type: allSelected
? SelectActionType.UnselectAll
: SelectActionType.SelectAll,
});
}, [allSelected, selectDispatch]);
return isSelectMode ? (
<PageToolbarOverflowMenuItem
label={allSelected ? 'Unselect All' : 'Select All'}
iconName={iconName}
onPress={onPressWrapper}
/>
) : null;
}
export default ArtistIndexSelectAllMenuItem;

@ -0,0 +1,68 @@
.footer {
composes: contentFooter from '~Components/Page/PageContentFooter.css';
align-items: center;
}
.buttons {
display: flex;
}
.actionButtons,
.deleteButtons {
display: flex;
gap: 10px;
}
.deleteButtons {
margin-left: 50px;
}
.selected {
display: flex;
justify-content: flex-end;
flex-grow: 1;
font-weight: bold;
}
@media only screen and (max-width: $breakpointMedium) {
.buttons {
justify-content: center;
width: 100%;
}
.selected {
justify-content: center;
margin-bottom: 20px;
width: 100%;
order: -1;
}
}
@media only screen and (max-width: $breakpointSmall) {
.footer {
display: flex;
flex-direction: column;
}
.buttons {
flex-direction: column;
margin-top: 20px;
gap: 20px;
}
.actionButtons,
.deleteButtons {
display: flex;
justify-content: center;
}
.deleteButtons {
margin-left: 0;
}
.selected {
justify-content: center;
order: -1;
}
}

@ -0,0 +1,11 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'actionButtons': string;
'buttons': string;
'deleteButtons': string;
'footer': string;
'selected': string;
}
export const cssExports: CssExports;
export default cssExports;

@ -0,0 +1,244 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { SelectActionType, useSelect } from 'App/SelectContext';
import { RENAME_ARTIST, RETAG_ARTIST } from 'Commands/commandNames';
import SpinnerButton from 'Components/Link/SpinnerButton';
import PageContentFooter from 'Components/Page/PageContentFooter';
import { kinds } from 'Helpers/Props';
import { saveArtistEditor } from 'Store/Actions/artistActions';
import { fetchRootFolders } from 'Store/Actions/settingsActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import RetagArtistModal from './AudioTags/RetagArtistModal';
import DeleteArtistModal from './Delete/DeleteArtistModal';
import EditArtistModal from './Edit/EditArtistModal';
import OrganizeArtistModal from './Organize/OrganizeArtistModal';
import TagsModal from './Tags/TagsModal';
import styles from './ArtistIndexSelectFooter.css';
const artistEditorSelector = createSelector(
(state) => state.artist,
(artist) => {
const { isSaving, isDeleting, deleteError } = artist;
return {
isSaving,
isDeleting,
deleteError,
};
}
);
function ArtistIndexSelectFooter() {
const { isSaving, isDeleting, deleteError } =
useSelector(artistEditorSelector);
const isOrganizingArtist = useSelector(
createCommandExecutingSelector(RENAME_ARTIST)
);
const isRetaggingArtist = useSelector(
createCommandExecutingSelector(RETAG_ARTIST)
);
const dispatch = useDispatch();
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isOrganizeModalOpen, setIsOrganizeModalOpen] = useState(false);
const [isRetaggingModalOpen, setIsRetaggingModalOpen] = useState(false);
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isSavingArtist, setIsSavingArtist] = useState(false);
const [isSavingTags, setIsSavingTags] = useState(false);
const [selectState, selectDispatch] = useSelect();
const { selectedState } = selectState;
const artistIds = useMemo(() => {
return getSelectedIds(selectedState);
}, [selectedState]);
const selectedCount = artistIds.length;
const onEditPress = useCallback(() => {
setIsEditModalOpen(true);
}, [setIsEditModalOpen]);
const onEditModalClose = useCallback(() => {
setIsEditModalOpen(false);
}, [setIsEditModalOpen]);
const onSavePress = useCallback(
(payload) => {
setIsSavingArtist(true);
setIsEditModalOpen(false);
dispatch(
saveArtistEditor({
...payload,
artistIds,
})
);
},
[artistIds, dispatch]
);
const onOrganizePress = useCallback(() => {
setIsOrganizeModalOpen(true);
}, [setIsOrganizeModalOpen]);
const onOrganizeModalClose = useCallback(() => {
setIsOrganizeModalOpen(false);
}, [setIsOrganizeModalOpen]);
const onRetagPress = useCallback(() => {
setIsRetaggingModalOpen(true);
}, [setIsRetaggingModalOpen]);
const onRetagModalClose = useCallback(() => {
setIsRetaggingModalOpen(false);
}, [setIsRetaggingModalOpen]);
const onTagsPress = useCallback(() => {
setIsTagsModalOpen(true);
}, [setIsTagsModalOpen]);
const onTagsModalClose = useCallback(() => {
setIsTagsModalOpen(false);
}, [setIsTagsModalOpen]);
const onApplyTagsPress = useCallback(
(tags, applyTags) => {
setIsSavingTags(true);
setIsTagsModalOpen(false);
dispatch(
saveArtistEditor({
artistIds,
tags,
applyTags,
})
);
},
[artistIds, dispatch]
);
const onDeletePress = useCallback(() => {
setIsDeleteModalOpen(true);
}, [setIsDeleteModalOpen]);
const onDeleteModalClose = useCallback(() => {
setIsDeleteModalOpen(false);
}, []);
useEffect(() => {
if (!isSaving) {
setIsSavingArtist(false);
setIsSavingTags(false);
}
}, [isSaving]);
useEffect(() => {
if (!isDeleting && !deleteError) {
selectDispatch({ type: SelectActionType.UnselectAll });
}
}, [isDeleting, deleteError, selectDispatch]);
useEffect(() => {
dispatch(fetchRootFolders());
}, [dispatch]);
const anySelected = selectedCount > 0;
return (
<PageContentFooter className={styles.footer}>
<div className={styles.buttons}>
<div className={styles.actionButtons}>
<SpinnerButton
isSpinning={isSaving && isSavingArtist}
isDisabled={!anySelected || isOrganizingArtist || isRetaggingArtist}
onPress={onEditPress}
>
{translate('Edit')}
</SpinnerButton>
<SpinnerButton
kind={kinds.WARNING}
isSpinning={isOrganizingArtist}
isDisabled={!anySelected || isOrganizingArtist || isRetaggingArtist}
onPress={onOrganizePress}
>
{translate('RenameFiles')}
</SpinnerButton>
<SpinnerButton
kind={kinds.WARNING}
isSpinning={isRetaggingArtist}
isDisabled={!anySelected || isOrganizingArtist || isRetaggingArtist}
onPress={onRetagPress}
>
{translate('WriteMetadataTags')}
</SpinnerButton>
<SpinnerButton
isSpinning={isSaving && isSavingTags}
isDisabled={!anySelected || isOrganizingArtist}
onPress={onTagsPress}
>
{translate('SetAppTags')}
</SpinnerButton>
</div>
<div className={styles.deleteButtons}>
<SpinnerButton
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={!anySelected || isDeleting}
onPress={onDeletePress}
>
{translate('Delete')}
</SpinnerButton>
</div>
</div>
<div className={styles.selected}>
{translate('CountArtistsSelected', { count: selectedCount })}
</div>
<EditArtistModal
isOpen={isEditModalOpen}
artistIds={artistIds}
onSavePress={onSavePress}
onModalClose={onEditModalClose}
/>
<TagsModal
isOpen={isTagsModalOpen}
artistIds={artistIds}
onApplyTagsPress={onApplyTagsPress}
onModalClose={onTagsModalClose}
/>
<OrganizeArtistModal
isOpen={isOrganizeModalOpen}
artistIds={artistIds}
onModalClose={onOrganizeModalClose}
/>
<RetagArtistModal
isOpen={isRetaggingModalOpen}
artistIds={artistIds}
onModalClose={onRetagModalClose}
/>
<DeleteArtistModal
isOpen={isDeleteModalOpen}
artistIds={artistIds}
onModalClose={onDeleteModalClose}
/>
</PageContentFooter>
);
}
export default ArtistIndexSelectFooter;

@ -0,0 +1,37 @@
import { IconDefinition } from '@fortawesome/fontawesome-common-types';
import React, { useCallback } from 'react';
import { SelectActionType, useSelect } from 'App/SelectContext';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
interface ArtistIndexSelectModeButtonProps {
label: string;
iconName: IconDefinition;
isSelectMode: boolean;
overflowComponent: React.FunctionComponent;
onPress: () => void;
}
function ArtistIndexSelectModeButton(props: ArtistIndexSelectModeButtonProps) {
const { label, iconName, isSelectMode, onPress } = props;
const [, selectDispatch] = useSelect();
const onPressWrapper = useCallback(() => {
if (isSelectMode) {
selectDispatch({
type: SelectActionType.Reset,
});
}
onPress();
}, [isSelectMode, onPress, selectDispatch]);
return (
<PageToolbarButton
label={label}
iconName={iconName}
onPress={onPressWrapper}
/>
);
}
export default ArtistIndexSelectModeButton;

@ -0,0 +1,38 @@
import { IconDefinition } from '@fortawesome/fontawesome-common-types';
import React, { useCallback } from 'react';
import { SelectActionType, useSelect } from 'App/SelectContext';
import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem';
interface ArtistIndexSelectModeMenuItemProps {
label: string;
iconName: IconDefinition;
isSelectMode: boolean;
onPress: () => void;
}
function ArtistIndexSelectModeMenuItem(
props: ArtistIndexSelectModeMenuItemProps
) {
const { label, iconName, isSelectMode, onPress } = props;
const [, selectDispatch] = useSelect();
const onPressWrapper = useCallback(() => {
if (isSelectMode) {
selectDispatch({
type: SelectActionType.Reset,
});
}
onPress();
}, [isSelectMode, onPress, selectDispatch]);
return (
<PageToolbarOverflowMenuItem
label={label}
iconName={iconName}
onPress={onPressWrapper}
/>
);
}
export default ArtistIndexSelectModeMenuItem;

@ -0,0 +1,21 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import RetagArtistModalContent from './RetagArtistModalContent';
interface RetagArtistModalProps {
isOpen: boolean;
artistIds: number[];
onModalClose: () => void;
}
function RetagArtistModal(props: RetagArtistModalProps) {
const { isOpen, onModalClose, ...otherProps } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<RetagArtistModalContent {...otherProps} onModalClose={onModalClose} />
</Modal>
);
}
export default RetagArtistModal;

@ -0,0 +1,8 @@
.retagIcon {
margin-left: 5px;
}
.message {
margin-top: 20px;
margin-bottom: 10px;
}

@ -0,0 +1,8 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'message': string;
'retagIcon': string;
}
export const cssExports: CssExports;
export default cssExports;

@ -0,0 +1,85 @@
import { orderBy } from 'lodash';
import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Artist from 'Artist/Artist';
import { RETAG_ARTIST } from 'Commands/commandNames';
import Alert from 'Components/Alert';
import Icon from 'Components/Icon';
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 { icons, kinds } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions';
import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
import translate from 'Utilities/String/translate';
import styles from './RetagArtistModalContent.css';
interface RetagArtistModalContentProps {
artistIds: number[];
onModalClose: () => void;
}
function RetagArtistModalContent(props: RetagArtistModalContentProps) {
const { artistIds, onModalClose } = props;
const allArtists: Artist[] = useSelector(createAllArtistSelector());
const dispatch = useDispatch();
const artistNames = useMemo(() => {
const artists = artistIds.map((id) => {
return allArtists.find((a) => a.id === id);
});
const sorted = orderBy(artists, ['sortName']);
return sorted.map((a) => a.artistName);
}, [artistIds, allArtists]);
const onRetagPress = useCallback(() => {
dispatch(
executeCommand({
name: RETAG_ARTIST,
artistIds,
})
);
onModalClose();
}, [artistIds, onModalClose, dispatch]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('RetagSelectedArtists')}</ModalHeader>
<ModalBody>
<Alert>
Tip: To preview the tags that will be written, select "Cancel", then
select any artist name and use the
<Icon className={styles.retagIcon} name={icons.RETAG} />
</Alert>
<div className={styles.message}>
Are you sure you want to retag all files in the {artistNames.length}{' '}
selected artist?
</div>
<ul>
{artistNames.map((artistName) => {
return <li key={artistName}>{artistName}</li>;
})}
</ul>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button kind={kinds.DANGER} onPress={onRetagPress}>
{translate('Retag')}
</Button>
</ModalFooter>
</ModalContent>
);
}
export default RetagArtistModalContent;

@ -0,0 +1,24 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import DeleteArtistModalContent from './DeleteArtistModalContent';
interface DeleteArtistModalProps {
isOpen: boolean;
artistIds: number[];
onModalClose(): void;
}
function DeleteArtistModal(props: DeleteArtistModalProps) {
const { isOpen, artistIds, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<DeleteArtistModalContent
artistIds={artistIds}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default DeleteArtistModal;

@ -0,0 +1,13 @@
.message {
margin-top: 20px;
margin-bottom: 10px;
}
.pathContainer {
margin-left: 5px;
}
.path {
margin-left: 5px;
color: var(--dangerColor);
}

@ -0,0 +1,9 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'message': string;
'path': string;
'pathContainer': string;
}
export const cssExports: CssExports;
export default cssExports;

@ -0,0 +1,165 @@
import { orderBy } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Artist from 'Artist/Artist';
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 { bulkDeleteArtist, setDeleteOption } from 'Store/Actions/artistActions';
import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
import translate from 'Utilities/String/translate';
import styles from './DeleteArtistModalContent.css';
interface DeleteArtistModalContentProps {
artistIds: number[];
onModalClose(): void;
}
const selectDeleteOptions = createSelector(
(state: AppState) => state.artist.deleteOptions,
(deleteOptions) => deleteOptions
);
function DeleteArtistModalContent(props: DeleteArtistModalContentProps) {
const { artistIds, onModalClose } = props;
const { addImportListExclusion } = useSelector(selectDeleteOptions);
const allArtists: Artist[] = useSelector(createAllArtistSelector());
const dispatch = useDispatch();
const [deleteFiles, setDeleteFiles] = useState(false);
const artists = useMemo(() => {
const artists = artistIds.map((id) => {
return allArtists.find((a) => a.id === id);
});
return orderBy(artists, ['sortName']);
}, [artistIds, allArtists]);
const onDeleteFilesChange = useCallback(
({ value }) => {
setDeleteFiles(value);
},
[setDeleteFiles]
);
const onDeleteOptionChange = useCallback(
({ name, value }: { name: string; value: boolean }) => {
dispatch(
setDeleteOption({
[name]: value,
})
);
},
[dispatch]
);
const onDeleteArtistConfirmed = useCallback(() => {
setDeleteFiles(false);
dispatch(
bulkDeleteArtist({
artistIds,
deleteFiles,
addImportListExclusion,
})
);
onModalClose();
}, [
artistIds,
deleteFiles,
addImportListExclusion,
setDeleteFiles,
dispatch,
onModalClose,
]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('DeleteSelectedArtists')}</ModalHeader>
<ModalBody>
<div>
<FormGroup>
<FormLabel>{translate('AddListExclusion')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="addImportListExclusion"
value={addImportListExclusion}
helpText={translate('AddListExclusionHelpText')}
onChange={onDeleteOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{artists.length > 1
? translate('DeleteArtistFolders')
: translate('DeleteArtistFolder')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="deleteFiles"
value={deleteFiles}
helpText={
artists.length > 1
? translate('DeleteArtistFoldersHelpText')
: translate('DeleteArtistFolderHelpText')
}
kind={kinds.DANGER}
onChange={onDeleteFilesChange}
/>
</FormGroup>
</div>
<div className={styles.message}>
{deleteFiles
? translate('DeleteArtistFolderCountWithFilesConfirmation', {
count: artists.length,
})
: translate('DeleteArtistFolderCountConfirmation', {
count: artists.length,
})}
</div>
<ul>
{artists.map((a) => {
return (
<li key={a.artistName}>
<span>{a.artistName}</span>
{deleteFiles && (
<span className={styles.pathContainer}>
-<span className={styles.path}>{a.path}</span>
</span>
)}
</li>
);
})}
</ul>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button kind={kinds.DANGER} onPress={onDeleteArtistConfirmed}>
{translate('Delete')}
</Button>
</ModalFooter>
</ModalContent>
);
}
export default DeleteArtistModalContent;

@ -0,0 +1,26 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import EditArtistModalContent from './EditArtistModalContent';
interface EditArtistModalProps {
isOpen: boolean;
artistIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
function EditArtistModal(props: EditArtistModalProps) {
const { isOpen, artistIds, onSavePress, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<EditArtistModalContent
artistIds={artistIds}
onSavePress={onSavePress}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default EditArtistModal;

@ -0,0 +1,16 @@
.modalFooter {
composes: modalFooter from '~Components/Modal/ModalFooter.css';
justify-content: space-between;
}
.selected {
font-weight: bold;
}
@media only screen and (max-width: $breakpointExtraSmall) {
.modalFooter {
flex-direction: column;
gap: 10px;
}
}

@ -0,0 +1,8 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'modalFooter': string;
'selected': string;
}
export const cssExports: CssExports;
export default cssExports;

@ -0,0 +1,233 @@
import React, { useCallback, useState } from 'react';
import MoveArtistModal from 'Artist/MoveArtist/MoveArtistModal';
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 } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './EditArtistModalContent.css';
interface SavePayload {
monitored?: boolean;
qualityProfileId?: number;
metadataProfileId?: number;
rootFolderPath?: string;
moveFiles?: boolean;
}
interface EditArtistModalContentProps {
artistIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
const NO_CHANGE = 'noChange';
const monitoredOptions = [
{
key: NO_CHANGE,
get value() {
return translate('NoChange');
},
disabled: true,
},
{
key: 'monitored',
get value() {
return translate('Monitored');
},
},
{
key: 'unmonitored',
get value() {
return translate('Unmonitored');
},
},
];
function EditArtistModalContent(props: EditArtistModalContentProps) {
const { artistIds, onSavePress, onModalClose } = props;
const [monitored, setMonitored] = useState(NO_CHANGE);
const [qualityProfileId, setQualityProfileId] = useState<string | number>(
NO_CHANGE
);
const [metadataProfileId, setMetadataProfileId] = useState<string | number>(
NO_CHANGE
);
const [rootFolderPath, setRootFolderPath] = useState(NO_CHANGE);
const [isConfirmMoveModalOpen, setIsConfirmMoveModalOpen] = useState(false);
const save = useCallback(
(moveFiles) => {
let hasChanges = false;
const payload: SavePayload = {};
if (monitored !== NO_CHANGE) {
hasChanges = true;
payload.monitored = monitored === 'monitored';
}
if (qualityProfileId !== NO_CHANGE) {
hasChanges = true;
payload.qualityProfileId = qualityProfileId as number;
}
if (metadataProfileId !== NO_CHANGE) {
hasChanges = true;
payload.metadataProfileId = metadataProfileId as number;
}
if (rootFolderPath !== NO_CHANGE) {
hasChanges = true;
payload.rootFolderPath = rootFolderPath;
payload.moveFiles = moveFiles;
}
if (hasChanges) {
onSavePress(payload);
}
onModalClose();
},
[
monitored,
qualityProfileId,
metadataProfileId,
rootFolderPath,
onSavePress,
onModalClose,
]
);
const onInputChange = useCallback(
({ name, value }) => {
switch (name) {
case 'monitored':
setMonitored(value);
break;
case 'qualityProfileId':
setQualityProfileId(value);
break;
case 'metadataProfileId':
setMetadataProfileId(value);
break;
case 'rootFolderPath':
setRootFolderPath(value);
break;
default:
console.warn('EditArtistModalContent Unknown Input');
}
},
[setMonitored]
);
const onSavePressWrapper = useCallback(() => {
if (rootFolderPath === NO_CHANGE) {
save(false);
} else {
setIsConfirmMoveModalOpen(true);
}
}, [rootFolderPath, save]);
const onDoNotMoveArtistPress = useCallback(() => {
setIsConfirmMoveModalOpen(false);
save(false);
}, [setIsConfirmMoveModalOpen, save]);
const onMoveArtistPress = useCallback(() => {
setIsConfirmMoveModalOpen(false);
save(true);
}, [setIsConfirmMoveModalOpen, save]);
const selectedCount = artistIds.length;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('EditSelectedArtists')}</ModalHeader>
<ModalBody>
<FormGroup>
<FormLabel>{translate('Monitored')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="monitored"
value={monitored}
values={monitoredOptions}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('QualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
value={qualityProfileId}
includeNoChange={true}
includeNoChangeDisabled={false}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('MetadataProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.METADATA_PROFILE_SELECT}
name="metadataProfileId"
value={metadataProfileId}
includeNoChange={true}
includeNoChangeDisabled={false}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('RootFolder')}</FormLabel>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
value={rootFolderPath}
includeNoChange={true}
includeNoChangeDisabled={false}
selectedValueOptions={{ includeFreeSpace: false }}
helpText={translate('ArtistsEditRootFolderHelpText')}
onChange={onInputChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('CountArtistsSelected', { count: selectedCount })}
</div>
<div>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button onPress={onSavePressWrapper}>
{translate('ApplyChanges')}
</Button>
</div>
</ModalFooter>
<MoveArtistModal
isOpen={isConfirmMoveModalOpen}
destinationRootFolder={rootFolderPath}
onSavePress={onDoNotMoveArtistPress}
onMoveArtistPress={onMoveArtistPress}
/>
</ModalContent>
);
}
export default EditArtistModalContent;

@ -0,0 +1,21 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import OrganizeArtistModalContent from './OrganizeArtistModalContent';
interface OrganizeArtistModalProps {
isOpen: boolean;
artistIds: number[];
onModalClose: () => void;
}
function OrganizeArtistModal(props: OrganizeArtistModalProps) {
const { isOpen, onModalClose, ...otherProps } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<OrganizeArtistModalContent {...otherProps} onModalClose={onModalClose} />
</Modal>
);
}
export default OrganizeArtistModal;

@ -0,0 +1,8 @@
.renameIcon {
margin-left: 5px;
}
.message {
margin-top: 20px;
margin-bottom: 10px;
}

@ -0,0 +1,8 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'message': string;
'renameIcon': string;
}
export const cssExports: CssExports;
export default cssExports;

@ -0,0 +1,91 @@
import { orderBy } from 'lodash';
import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Artist from 'Artist/Artist';
import { RENAME_ARTIST } from 'Commands/commandNames';
import Alert from 'Components/Alert';
import Icon from 'Components/Icon';
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 { icons, kinds } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions';
import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
import translate from 'Utilities/String/translate';
import styles from './OrganizeArtistModalContent.css';
interface OrganizeArtistModalContentProps {
artistIds: number[];
onModalClose: () => void;
}
function OrganizeArtistModalContent(props: OrganizeArtistModalContentProps) {
const { artistIds, onModalClose } = props;
const allArtists: Artist[] = useSelector(createAllArtistSelector());
const dispatch = useDispatch();
const artistNames = useMemo(() => {
const artists = artistIds.reduce((acc: Artist[], id) => {
const a = allArtists.find((a) => a.id === id);
if (a) {
acc.push(a);
}
return acc;
}, []);
const sorted = orderBy(artists, ['sortName']);
return sorted.map((a) => a.artistName);
}, [artistIds, allArtists]);
const onOrganizePress = useCallback(() => {
dispatch(
executeCommand({
name: RENAME_ARTIST,
artistIds,
})
);
onModalClose();
}, [artistIds, onModalClose, dispatch]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('OrganizeSelectedArtists')}</ModalHeader>
<ModalBody>
<Alert>
Tip: To preview a rename, select "Cancel", then select any artist name
and use the
<Icon className={styles.renameIcon} name={icons.ORGANIZE} />
</Alert>
<div className={styles.message}>
Are you sure you want to organize all files in the{' '}
{artistNames.length} selected artist?
</div>
<ul>
{artistNames.map((artistName) => {
return <li key={artistName}>{artistName}</li>;
})}
</ul>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button kind={kinds.DANGER} onPress={onOrganizePress}>
{translate('Organize')}
</Button>
</ModalFooter>
</ModalContent>
);
}
export default OrganizeArtistModalContent;

@ -0,0 +1,22 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import TagsModalContent from './TagsModalContent';
interface TagsModalProps {
isOpen: boolean;
artistIds: number[];
onApplyTagsPress: (tags: number[], applyTags: string) => void;
onModalClose: () => void;
}
function TagsModal(props: TagsModalProps) {
const { isOpen, onModalClose, ...otherProps } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<TagsModalContent {...otherProps} onModalClose={onModalClose} />
</Modal>
);
}
export default TagsModal;

@ -0,0 +1,12 @@
.renameIcon {
margin-left: 5px;
}
.message {
margin-top: 20px;
margin-bottom: 10px;
}
.result {
padding-top: 4px;
}

@ -0,0 +1,9 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'message': string;
'renameIcon': string;
'result': string;
}
export const cssExports: CssExports;
export default cssExports;

@ -0,0 +1,188 @@
import { uniq } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import Artist from 'Artist/Artist';
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 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 { inputTypes, kinds, sizes } from 'Helpers/Props';
import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import translate from 'Utilities/String/translate';
import styles from './TagsModalContent.css';
interface TagsModalContentProps {
artistIds: number[];
onApplyTagsPress: (tags: number[], applyTags: string) => void;
onModalClose: () => void;
}
function TagsModalContent(props: TagsModalContentProps) {
const { artistIds, onModalClose, onApplyTagsPress } = props;
const allArtists: Artist[] = useSelector(createAllArtistSelector());
const tagList = useSelector(createTagsSelector());
const [tags, setTags] = useState<number[]>([]);
const [applyTags, setApplyTags] = useState('add');
const artistTags = useMemo(() => {
const tags = artistIds.reduce((acc: number[], id) => {
const a = allArtists.find((a) => a.id === id);
if (a) {
acc.push(...a.tags);
}
return acc;
}, []);
return uniq(tags);
}, [artistIds, allArtists]);
const onTagsChange = useCallback(
({ value }) => {
setTags(value);
},
[setTags]
);
const onApplyTagsChange = useCallback(
({ value }) => {
setApplyTags(value);
},
[setApplyTags]
);
const onApplyPress = useCallback(() => {
onApplyTagsPress(tags, applyTags);
}, [tags, applyTags, onApplyTagsPress]);
const applyTagsOptions = [
{
key: 'add',
value: translate('Add'),
},
{
key: 'remove',
value: translate('Remove'),
},
{
key: 'replace',
value: translate('Replace'),
},
];
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('Tags')}</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
value={tags}
onChange={onTagsChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ApplyTags')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="applyTags"
value={applyTags}
values={applyTagsOptions}
helpTexts={[
translate('ApplyTagsHelpTextHowToApplyArtists'),
translate('ApplyTagsHelpTextAdd'),
translate('ApplyTagsHelpTextRemove'),
translate('ApplyTagsHelpTextReplace'),
]}
onChange={onApplyTagsChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Result')}</FormLabel>
<div className={styles.result}>
{artistTags.map((id) => {
const tag = tagList.find((t) => t.id === id);
if (!tag) {
return null;
}
const removeTag =
(applyTags === 'remove' && tags.indexOf(id) > -1) ||
(applyTags === 'replace' && tags.indexOf(id) === -1);
return (
<Label
key={tag.id}
title={
removeTag
? translate('RemovingTag')
: translate('ExistingTag')
}
kind={removeTag ? kinds.INVERSE : kinds.INFO}
size={sizes.LARGE}
>
{tag.label}
</Label>
);
})}
{(applyTags === 'add' || applyTags === 'replace') &&
tags.map((id) => {
const tag = tagList.find((t) => t.id === id);
if (!tag) {
return null;
}
if (artistTags.indexOf(id) > -1) {
return null;
}
return (
<Label
key={tag.id}
title={translate('AddingTag')}
kind={kinds.SUCCESS}
size={sizes.LARGE}
>
{tag.label}
</Label>
);
})}
</div>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button kind={kinds.PRIMARY} onPress={onApplyPress}>
{translate('Apply')}
</Button>
</ModalFooter>
</ModalContent>
);
}
export default TagsModalContent;

@ -12,7 +12,8 @@ function createMapStateToProps() {
(state, { value }) => value,
(state, { includeMissingValue }) => includeMissingValue,
(state, { includeNoChange }) => includeNoChange,
(rootFolders, value, includeMissingValue, includeNoChange) => {
(state, { includeNoChangeDisabled }) => includeNoChangeDisabled,
(rootFolders, value, includeMissingValue, includeNoChange, includeNoChangeDisabled = true) => {
const values = rootFolders.items.map((rootFolder) => {
return {
key: rootFolder.path,
@ -26,9 +27,8 @@ function createMapStateToProps() {
if (includeNoChange) {
values.unshift({
key: 'noChange',
value: '',
name: 'No Change',
isDisabled: true,
value: 'No Change',
isDisabled: includeNoChangeDisabled,
isMissing: false
});
}
@ -46,7 +46,6 @@ function createMapStateToProps() {
values.push({
key: '',
value: '',
name: '',
isDisabled: true,
isHidden: true
});
@ -54,8 +53,7 @@ function createMapStateToProps() {
values.push({
key: ADD_NEW_KEY,
value: '',
name: 'Add a new path'
value: 'Add a new path'
});
return {

@ -1,7 +1,7 @@
import PropTypes from 'prop-types';
import React from 'react';
import translate from 'Utilities/String/translate';
import SelectInput from './SelectInput';
import EnhancedSelectInput from './EnhancedSelectInput';
const artistTypeOptions = [
{ key: 'standard', value: 'Standard' },
@ -14,6 +14,7 @@ function SeriesTypeSelectInput(props) {
const {
includeNoChange,
includeNoChangeDisabled = true,
includeMixed
} = props;
@ -21,7 +22,7 @@ function SeriesTypeSelectInput(props) {
values.unshift({
key: 'noChange',
value: translate('NoChange'),
disabled: true
disabled: includeNoChangeDisabled
});
}
@ -34,7 +35,7 @@ function SeriesTypeSelectInput(props) {
}
return (
<SelectInput
<EnhancedSelectInput
{...props}
values={values}
/>
@ -43,6 +44,7 @@ function SeriesTypeSelectInput(props) {
SeriesTypeSelectInput.propTypes = {
includeNoChange: PropTypes.bool.isRequired,
includeNoChangeDisabled: PropTypes.bool,
includeMixed: PropTypes.bool.isRequired
};

@ -8,14 +8,6 @@
@media only screen and (max-width: $breakpointSmall) {
.contentFooter {
display: block;
div {
margin-top: 10px;
&:first-child {
margin-top: 0;
}
}
}
}

@ -0,0 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'icon': string;
}
export const cssExports: CssExports;
export default cssExports;

@ -0,0 +1,41 @@
import { IconDefinition } from '@fortawesome/fontawesome-common-types';
import React from 'react';
import MenuItem from 'Components/Menu/MenuItem';
import SpinnerIcon from 'Components/SpinnerIcon';
import styles from './PageToolbarOverflowMenuItem.css';
interface PageToolbarOverflowMenuItemProps {
iconName: IconDefinition;
spinningName?: IconDefinition;
isDisabled?: boolean;
isSpinning?: boolean;
showIndicator?: boolean;
label: string;
text?: string;
onPress: () => void;
}
function PageToolbarOverflowMenuItem(props: PageToolbarOverflowMenuItemProps) {
const {
iconName,
spinningName,
label,
isDisabled,
isSpinning = false,
...otherProps
} = props;
return (
<MenuItem key={label} isDisabled={isDisabled || isSpinning} {...otherProps}>
<SpinnerIcon
className={styles.icon}
name={iconName}
spinningName={spinningName}
isSpinning={isSpinning}
/>
{label}
</MenuItem>
);
}
export default PageToolbarOverflowMenuItem;

@ -4,12 +4,11 @@ import React, { Component } from 'react';
import Measure from 'Components/Measure';
import Menu from 'Components/Menu/Menu';
import MenuContent from 'Components/Menu/MenuContent';
import MenuItem from 'Components/Menu/MenuItem';
import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
import SpinnerIcon from 'Components/SpinnerIcon';
import { forEach } from 'Helpers/elementChildren';
import { align, icons } from 'Helpers/Props';
import dimensions from 'Styles/Variables/dimensions';
import PageToolbarOverflowMenuItem from './PageToolbarOverflowMenuItem';
import styles from './PageToolbarSection.css';
const BUTTON_WIDTH = parseInt(dimensions.toolbarButtonWidth);
@ -168,28 +167,15 @@ class PageToolbarSection extends Component {
{
overflowItems.map((item) => {
const {
iconName,
spinningName,
label,
isDisabled,
isSpinning,
...otherProps
overflowComponent: OverflowComponent = PageToolbarOverflowMenuItem
} = item;
return (
<MenuItem
<OverflowComponent
key={label}
isDisabled={isDisabled || isSpinning}
{...otherProps}
>
<SpinnerIcon
className={styles.overflowMenuItemIcon}
name={iconName}
spinningName={spinningName}
isSpinning={isSpinning}
/>
{label}
</MenuItem>
{...item}
/>
);
})
}

@ -21,6 +21,7 @@ function SpinnerIcon(props) {
}
SpinnerIcon.propTypes = {
className: PropTypes.string,
name: PropTypes.object.isRequired,
spinningName: PropTypes.object.isRequired,
isSpinning: PropTypes.bool.isRequired

@ -155,6 +155,8 @@ export const defaultState = {
error: null,
isSaving: false,
saveError: null,
isDeleting: false,
deleteError: null,
items: [],
sortKey: 'sortName',
sortDirection: sortDirections.ASCENDING,
@ -179,6 +181,8 @@ 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';
@ -215,6 +219,8 @@ export const deleteArtist = createThunk(DELETE_ARTIST, (payload) => {
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 {
@ -400,8 +406,87 @@ export const actionHandlers = handleThunks({
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
}));
});
}
});
//

@ -182,10 +182,18 @@ export const actionHandlers = handleThunks({
promise.done((data) => {
dispatch(batchActions([
...data.map((artist) => {
const {
images,
rootFolderPath,
statistics,
...propsToUpdate
} = artist;
return updateItem({
id: artist.id,
section: 'artist',
...artist
...propsToUpdate
});
}),

@ -39,7 +39,7 @@ module.exports = {
themeDarkColor: '#494949',
themeLightColor: '#595959',
pageBackground: '#202020',
pageFooterBackgroud: 'rgba(0, 0, 0, .25)',
pageFooterBackground: 'rgba(0, 0, 0, .25)',
torrentColor: '#00853d',
usenetColor: '#17b1d9',

@ -39,7 +39,7 @@ module.exports = {
themeDarkColor: '#353535',
themeLightColor: '#1d563d',
pageBackground: '#f5f7fa',
pageFooterBackgroud: '#f1f1f1',
pageFooterBackground: '#f1f1f1',
torrentColor: '#00853d',
usenetColor: '#17b1d9',

@ -21,6 +21,7 @@
"AddIndexer": "Add Indexer",
"AddIndexerImplementation": "Add Indexer - {implementationName}",
"AddListExclusion": "Add List Exclusion",
"AddListExclusionHelpText": "Prevent artists from being added to {appName} by lists",
"AddMetadataProfile": "Add Metadata Profile",
"AddMissing": "Add missing",
"AddNew": "Add New",
@ -104,6 +105,7 @@
"ArtistProgressBarText": "{trackFileCount} / {trackCount} (Total: {totalTrackCount})",
"ArtistType": "Artist Type",
"Artists": "Artists",
"ArtistsEditRootFolderHelpText": "Moving artists to the same root folder can be used to rename artist folders to match updated name or naming format",
"AudioInfo": "Audio Info",
"AuthBasic": "Basic (Browser Popup)",
"AuthForm": "Forms (Login Page)",
@ -195,7 +197,7 @@
"CopyUsingHardlinksHelpTextWarning": "Occasionally, file locks may prevent renaming files that are being seeded. You may temporarily disable seeding and use Lidarr's rename function as a work around.",
"CouldntFindAnyResultsForTerm": "Couldn't find any results for '{0}'",
"CountAlbums": "{albumCount} albums",
"CountArtistsSelected": "{selectedCount} artist(s) selected",
"CountArtistsSelected": "{count} artist(s) selected",
"CountDownloadClientsSelected": "{selectedCount} download client(s) selected",
"CountImportListsSelected": "{selectedCount} import list(s) selected",
"CountIndexersSelected": "{selectedCount} indexer(s) selected",
@ -231,6 +233,12 @@
"DelayingDownloadUntilInterp": "Delaying download until {0} at {1}",
"Delete": "Delete",
"DeleteArtist": "Delete Selected Artist",
"DeleteArtistFolder": "Delete Artist Folder",
"DeleteArtistFolderCountConfirmation": "Are you sure you want to delete {count} selected artists?",
"DeleteArtistFolderCountWithFilesConfirmation": "Are you sure you want to delete {count} selected artists and all contents?",
"DeleteArtistFolderHelpText": "Delete the artist folder and its contents",
"DeleteArtistFolders": "Delete Artist Folders",
"DeleteArtistFoldersHelpText": "Delete the artist folders and all their contents",
"DeleteBackup": "Delete Backup",
"DeleteBackupMessageText": "Are you sure you want to delete the backup '{name}'?",
"DeleteCondition": "Delete Condition",
@ -265,6 +273,7 @@
"DeleteRootFolder": "Delete Root Folder",
"DeleteRootFolderMessageText": "Are you sure you want to delete the root folder '{name}'?",
"DeleteSelected": "Delete Selected",
"DeleteSelectedArtists": "Delete Selected Artists",
"DeleteSelectedDownloadClients": "Delete Selected Download Client(s)",
"DeleteSelectedDownloadClientsMessageText": "Are you sure you want to delete {count} selected download client(s)?",
"DeleteSelectedImportLists": "Delete Import List(s)",
@ -334,6 +343,7 @@
"EditReleaseProfile": "Edit Release Profile",
"EditRemotePathMapping": "Edit Remote Path Mapping",
"EditRootFolder": "Edit Root Folder",
"EditSelectedArtists": "Edit Selected Artists",
"EditSelectedDownloadClients": "Edit Selected Download Clients",
"EditSelectedImportLists": "Edit Selected Import Lists",
"EditSelectedIndexers": "Edit Selected Indexers",
@ -684,7 +694,7 @@
"OpenBrowserOnStart": "Open browser on start",
"Options": "Options",
"Organize": "Organize",
"OrganizeArtist": "Organize Selected Artist",
"OrganizeSelectedArtists": "Organize Selected Artists",
"Original": "Original",
"Other": "Other",
"OutputPath": "Output Path",
@ -817,6 +827,7 @@
"RemoveTagRemovingTag": "Removing tag",
"RemovedFromTaskQueue": "Removed from task queue",
"RemovingTag": "Removing tag",
"RenameFiles": "Rename Files",
"RenameTracks": "Rename Tracks",
"RenameTracksHelpText": "Lidarr will use the existing file name if renaming is disabled",
"Renamed": "Renamed",
@ -853,6 +864,7 @@
"RestoreBackupAdditionalInfo": "Note: Lidarr will automatically restart and reload the UI during the restore process.",
"Result": "Result",
"Retag": "Retag",
"RetagSelectedArtists": "Retag Selected Artists",
"Retagged": "Retagged",
"Retention": "Retention",
"RetentionHelpText": "Usenet only: Set to zero to set for unlimited retention",
@ -899,6 +911,7 @@
"SelectTracks": "Select Tracks",
"SelectedCountArtistsSelectedInterp": "{selectedCount} Artist(s) Selected",
"SendAnonymousUsageData": "Send Anonymous Usage Data",
"SetAppTags": "Set {appName} Tags",
"SetPermissions": "Set Permissions",
"SetPermissionsLinuxHelpText": "Should chmod be run when files are imported/renamed?",
"SetPermissionsLinuxHelpTextWarning": "If you're unsure what these settings do, do not alter them.",

Loading…
Cancel
Save