diff --git a/frontend/src/AlbumStudio/AlbumStudioFooter.js b/frontend/src/AlbumStudio/AlbumStudioFooter.js index 8543a0347..f579d0dd0 100644 --- a/frontend/src/AlbumStudio/AlbumStudioFooter.js +++ b/frontend/src/AlbumStudio/AlbumStudioFooter.js @@ -146,7 +146,7 @@ class AlbumStudioFooter extends Component {
- {translate('CountArtistsSelected', { selectedCount })} + {translate('CountArtistsSelected', { count: selectedCount })}
{ // eslint-disable-next-line @typescript-eslint/no-explicit-any children: any; - isSelectMode: boolean; items: Array; } @@ -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( props: SelectProviderOptions ) { - const { isSelectMode, items } = props; + const { items } = props; const selectedState = getSelectedState(items, {}); const [state, dispatch] = React.useReducer(selectReducer, { @@ -142,12 +141,6 @@ export function SelectProvider( const value: [SelectState, Dispatch] = [state, dispatch]; - useEffect(() => { - if (!isSelectMode) { - dispatch({ type: SelectActionType.Reset }); - } - }, [isSelectMode]); - useEffect(() => { dispatch({ type: SelectActionType.UpdateItems, items }); }, [items]); diff --git a/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.js b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.js index 30a6929cd..8185a403a 100644 --- a/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.js +++ b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.js @@ -21,7 +21,7 @@ function OrganizeArtistModalContent(props) { return ( - {translate('OrganizeArtist')} + {translate('OrganizeSelectedArtists')} diff --git a/frontend/src/Artist/Index/ArtistIndex.css b/frontend/src/Artist/Index/ArtistIndex.css index 43b445c3c..908cb2d16 100644 --- a/frontend/src/Artist/Index/ArtistIndex.css +++ b/frontend/src/Artist/Index/ArtistIndex.css @@ -13,6 +13,7 @@ .contentBody { composes: contentBody from '~Components/Page/PageContentBody.css'; + position: relative; display: flex; flex-direction: column; } diff --git a/frontend/src/Artist/Index/ArtistIndex.tsx b/frontend/src/Artist/Index/ArtistIndex.tsx index 345ce10e7..c58339d99 100644 --- a/frontend/src/Artist/Index/ArtistIndex.tsx +++ b/frontend/src/Artist/Index/ArtistIndex.tsx @@ -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 ( - + @@ -232,13 +236,19 @@ const ArtistIndex = withScrollPosition((props: ArtistIndexProps) => { - - {isSelectMode ? : null} + { ) : null} - {isLoaded && !!jumpBarItems.order.length ? ( { /> ) : null}
+ + {isSelectMode ? : null} + {view === 'posters' ? ( - ); + ) : null; } export default ArtistIndexSelectAllButton; diff --git a/frontend/src/Artist/Index/Select/ArtistIndexSelectAllMenuItem.tsx b/frontend/src/Artist/Index/Select/ArtistIndexSelectAllMenuItem.tsx new file mode 100644 index 000000000..332ac0f3e --- /dev/null +++ b/frontend/src/Artist/Index/Select/ArtistIndexSelectAllMenuItem.tsx @@ -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 ? ( + + ) : null; +} + +export default ArtistIndexSelectAllMenuItem; diff --git a/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.css b/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.css new file mode 100644 index 000000000..b226a06a0 --- /dev/null +++ b/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.css @@ -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; + } +} diff --git a/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.css.d.ts b/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.css.d.ts new file mode 100644 index 000000000..7f02229e3 --- /dev/null +++ b/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.css.d.ts @@ -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; diff --git a/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.tsx b/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.tsx new file mode 100644 index 000000000..108ce466b --- /dev/null +++ b/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.tsx @@ -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 ( + +
+
+ + {translate('Edit')} + + + + {translate('RenameFiles')} + + + + {translate('WriteMetadataTags')} + + + + {translate('SetAppTags')} + +
+ +
+ + {translate('Delete')} + +
+
+ +
+ {translate('CountArtistsSelected', { count: selectedCount })} +
+ + + + + + + + + + +
+ ); +} + +export default ArtistIndexSelectFooter; diff --git a/frontend/src/Artist/Index/Select/ArtistIndexSelectModeButton.tsx b/frontend/src/Artist/Index/Select/ArtistIndexSelectModeButton.tsx new file mode 100644 index 000000000..8fa313f34 --- /dev/null +++ b/frontend/src/Artist/Index/Select/ArtistIndexSelectModeButton.tsx @@ -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 ( + + ); +} + +export default ArtistIndexSelectModeButton; diff --git a/frontend/src/Artist/Index/Select/ArtistIndexSelectModeMenuItem.tsx b/frontend/src/Artist/Index/Select/ArtistIndexSelectModeMenuItem.tsx new file mode 100644 index 000000000..df7992697 --- /dev/null +++ b/frontend/src/Artist/Index/Select/ArtistIndexSelectModeMenuItem.tsx @@ -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 ( + + ); +} + +export default ArtistIndexSelectModeMenuItem; diff --git a/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModal.tsx b/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModal.tsx new file mode 100644 index 000000000..5d5f1fb6a --- /dev/null +++ b/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModal.tsx @@ -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 ( + + + + ); +} + +export default RetagArtistModal; diff --git a/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModalContent.css b/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModalContent.css new file mode 100644 index 000000000..02c52edc8 --- /dev/null +++ b/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModalContent.css @@ -0,0 +1,8 @@ +.retagIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} diff --git a/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModalContent.css.d.ts b/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModalContent.css.d.ts new file mode 100644 index 000000000..c2556006e --- /dev/null +++ b/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModalContent.css.d.ts @@ -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; diff --git a/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModalContent.tsx b/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModalContent.tsx new file mode 100644 index 000000000..5e7d1f1ff --- /dev/null +++ b/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModalContent.tsx @@ -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 ( + + {translate('RetagSelectedArtists')} + + + + Tip: To preview the tags that will be written, select "Cancel", then + select any artist name and use the + + + +
+ Are you sure you want to retag all files in the {artistNames.length}{' '} + selected artist? +
+ +
    + {artistNames.map((artistName) => { + return
  • {artistName}
  • ; + })} +
+
+ + + + + + +
+ ); +} + +export default RetagArtistModalContent; diff --git a/frontend/src/Artist/Index/Select/Delete/DeleteArtistModal.tsx b/frontend/src/Artist/Index/Select/Delete/DeleteArtistModal.tsx new file mode 100644 index 000000000..c909d7406 --- /dev/null +++ b/frontend/src/Artist/Index/Select/Delete/DeleteArtistModal.tsx @@ -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 ( + + + + ); +} + +export default DeleteArtistModal; diff --git a/frontend/src/Artist/Index/Select/Delete/DeleteArtistModalContent.css b/frontend/src/Artist/Index/Select/Delete/DeleteArtistModalContent.css new file mode 100644 index 000000000..02a0514be --- /dev/null +++ b/frontend/src/Artist/Index/Select/Delete/DeleteArtistModalContent.css @@ -0,0 +1,13 @@ +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.pathContainer { + margin-left: 5px; +} + +.path { + margin-left: 5px; + color: var(--dangerColor); +} diff --git a/frontend/src/Artist/Index/Select/Delete/DeleteArtistModalContent.css.d.ts b/frontend/src/Artist/Index/Select/Delete/DeleteArtistModalContent.css.d.ts new file mode 100644 index 000000000..bcc2e2492 --- /dev/null +++ b/frontend/src/Artist/Index/Select/Delete/DeleteArtistModalContent.css.d.ts @@ -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; diff --git a/frontend/src/Artist/Index/Select/Delete/DeleteArtistModalContent.tsx b/frontend/src/Artist/Index/Select/Delete/DeleteArtistModalContent.tsx new file mode 100644 index 000000000..c367a4550 --- /dev/null +++ b/frontend/src/Artist/Index/Select/Delete/DeleteArtistModalContent.tsx @@ -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 ( + + {translate('DeleteSelectedArtists')} + + +
+ + {translate('AddListExclusion')} + + + + + + + {artists.length > 1 + ? translate('DeleteArtistFolders') + : translate('DeleteArtistFolder')} + + + 1 + ? translate('DeleteArtistFoldersHelpText') + : translate('DeleteArtistFolderHelpText') + } + kind={kinds.DANGER} + onChange={onDeleteFilesChange} + /> + +
+ +
+ {deleteFiles + ? translate('DeleteArtistFolderCountWithFilesConfirmation', { + count: artists.length, + }) + : translate('DeleteArtistFolderCountConfirmation', { + count: artists.length, + })} +
+ +
    + {artists.map((a) => { + return ( +
  • + {a.artistName} + + {deleteFiles && ( + + -{a.path} + + )} +
  • + ); + })} +
+
+ + + + + + +
+ ); +} + +export default DeleteArtistModalContent; diff --git a/frontend/src/Artist/Index/Select/Edit/EditArtistModal.tsx b/frontend/src/Artist/Index/Select/Edit/EditArtistModal.tsx new file mode 100644 index 000000000..bdb6726be --- /dev/null +++ b/frontend/src/Artist/Index/Select/Edit/EditArtistModal.tsx @@ -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 ( + + + + ); +} + +export default EditArtistModal; diff --git a/frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.css b/frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.css new file mode 100644 index 000000000..ea406894e --- /dev/null +++ b/frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.css @@ -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; + } +} diff --git a/frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.css.d.ts b/frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.css.d.ts new file mode 100644 index 000000000..cbf2d6328 --- /dev/null +++ b/frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.css.d.ts @@ -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; diff --git a/frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.tsx b/frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.tsx new file mode 100644 index 000000000..6d3ab132b --- /dev/null +++ b/frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.tsx @@ -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( + NO_CHANGE + ); + const [metadataProfileId, setMetadataProfileId] = useState( + 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 ( + + {translate('EditSelectedArtists')} + + + + {translate('Monitored')} + + + + + + {translate('QualityProfile')} + + + + + + {translate('MetadataProfile')} + + + + + + {translate('RootFolder')} + + + + + + +
+ {translate('CountArtistsSelected', { count: selectedCount })} +
+ +
+ + + +
+
+ + +
+ ); +} + +export default EditArtistModalContent; diff --git a/frontend/src/Artist/Index/Select/Organize/OrganizeArtistModal.tsx b/frontend/src/Artist/Index/Select/Organize/OrganizeArtistModal.tsx new file mode 100644 index 000000000..bec35222b --- /dev/null +++ b/frontend/src/Artist/Index/Select/Organize/OrganizeArtistModal.tsx @@ -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 ( + + + + ); +} + +export default OrganizeArtistModal; diff --git a/frontend/src/Artist/Index/Select/Organize/OrganizeArtistModalContent.css b/frontend/src/Artist/Index/Select/Organize/OrganizeArtistModalContent.css new file mode 100644 index 000000000..0b896f4ef --- /dev/null +++ b/frontend/src/Artist/Index/Select/Organize/OrganizeArtistModalContent.css @@ -0,0 +1,8 @@ +.renameIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} diff --git a/frontend/src/Artist/Index/Select/Organize/OrganizeArtistModalContent.css.d.ts b/frontend/src/Artist/Index/Select/Organize/OrganizeArtistModalContent.css.d.ts new file mode 100644 index 000000000..ae2303476 --- /dev/null +++ b/frontend/src/Artist/Index/Select/Organize/OrganizeArtistModalContent.css.d.ts @@ -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; diff --git a/frontend/src/Artist/Index/Select/Organize/OrganizeArtistModalContent.tsx b/frontend/src/Artist/Index/Select/Organize/OrganizeArtistModalContent.tsx new file mode 100644 index 000000000..8184abba7 --- /dev/null +++ b/frontend/src/Artist/Index/Select/Organize/OrganizeArtistModalContent.tsx @@ -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 ( + + {translate('OrganizeSelectedArtists')} + + + + Tip: To preview a rename, select "Cancel", then select any artist name + and use the + + + +
+ Are you sure you want to organize all files in the{' '} + {artistNames.length} selected artist? +
+ +
    + {artistNames.map((artistName) => { + return
  • {artistName}
  • ; + })} +
+
+ + + + + + +
+ ); +} + +export default OrganizeArtistModalContent; diff --git a/frontend/src/Artist/Index/Select/Tags/TagsModal.tsx b/frontend/src/Artist/Index/Select/Tags/TagsModal.tsx new file mode 100644 index 000000000..8635867e4 --- /dev/null +++ b/frontend/src/Artist/Index/Select/Tags/TagsModal.tsx @@ -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 ( + + + + ); +} + +export default TagsModal; diff --git a/frontend/src/Artist/Index/Select/Tags/TagsModalContent.css b/frontend/src/Artist/Index/Select/Tags/TagsModalContent.css new file mode 100644 index 000000000..63be9aadd --- /dev/null +++ b/frontend/src/Artist/Index/Select/Tags/TagsModalContent.css @@ -0,0 +1,12 @@ +.renameIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.result { + padding-top: 4px; +} diff --git a/frontend/src/Artist/Index/Select/Tags/TagsModalContent.css.d.ts b/frontend/src/Artist/Index/Select/Tags/TagsModalContent.css.d.ts new file mode 100644 index 000000000..9b4321dcc --- /dev/null +++ b/frontend/src/Artist/Index/Select/Tags/TagsModalContent.css.d.ts @@ -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; diff --git a/frontend/src/Artist/Index/Select/Tags/TagsModalContent.tsx b/frontend/src/Artist/Index/Select/Tags/TagsModalContent.tsx new file mode 100644 index 000000000..c41c0c896 --- /dev/null +++ b/frontend/src/Artist/Index/Select/Tags/TagsModalContent.tsx @@ -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([]); + 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 ( + + {translate('Tags')} + + +
+ + {translate('Tags')} + + + + + + {translate('ApplyTags')} + + + + + + {translate('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 ( + + ); + })} + + {(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 ( + + ); + })} +
+
+
+
+ + + + + + +
+ ); +} + +export default TagsModalContent; diff --git a/frontend/src/Components/Form/RootFolderSelectInputConnector.js b/frontend/src/Components/Form/RootFolderSelectInputConnector.js index 62077d68a..a7912a7f5 100644 --- a/frontend/src/Components/Form/RootFolderSelectInputConnector.js +++ b/frontend/src/Components/Form/RootFolderSelectInputConnector.js @@ -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 { diff --git a/frontend/src/Components/Form/SeriesTypeSelectInput.js b/frontend/src/Components/Form/SeriesTypeSelectInput.js index 52626a94a..e456178ff 100644 --- a/frontend/src/Components/Form/SeriesTypeSelectInput.js +++ b/frontend/src/Components/Form/SeriesTypeSelectInput.js @@ -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 ( - @@ -43,6 +44,7 @@ function SeriesTypeSelectInput(props) { SeriesTypeSelectInput.propTypes = { includeNoChange: PropTypes.bool.isRequired, + includeNoChangeDisabled: PropTypes.bool, includeMixed: PropTypes.bool.isRequired }; diff --git a/frontend/src/Components/Page/PageContentFooter.css b/frontend/src/Components/Page/PageContentFooter.css index 4709af871..61c63064a 100644 --- a/frontend/src/Components/Page/PageContentFooter.css +++ b/frontend/src/Components/Page/PageContentFooter.css @@ -8,14 +8,6 @@ @media only screen and (max-width: $breakpointSmall) { .contentFooter { display: block; - - div { - margin-top: 10px; - - &:first-child { - margin-top: 0; - } - } } } diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css new file mode 100644 index 000000000..b3cae8163 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css @@ -0,0 +1,3 @@ +.icon { + margin-right: 8px; +} diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css.d.ts b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css.d.ts new file mode 100644 index 000000000..2c598cbee --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css.d.ts @@ -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; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.tsx b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.tsx new file mode 100644 index 000000000..c97eb2a91 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.tsx @@ -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 ( + + + {label} + + ); +} + +export default PageToolbarOverflowMenuItem; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.js b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js index d64d11435..2d4aca718 100644 --- a/frontend/src/Components/Page/Toolbar/PageToolbarSection.js +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js @@ -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 ( - - - {label} - + {...item} + /> ); }) } diff --git a/frontend/src/Components/SpinnerIcon.js b/frontend/src/Components/SpinnerIcon.js index d21674d9e..5ae03ee66 100644 --- a/frontend/src/Components/SpinnerIcon.js +++ b/frontend/src/Components/SpinnerIcon.js @@ -21,6 +21,7 @@ function SpinnerIcon(props) { } SpinnerIcon.propTypes = { + className: PropTypes.string, name: PropTypes.object.isRequired, spinningName: PropTypes.object.isRequired, isSpinning: PropTypes.bool.isRequired diff --git a/frontend/src/Store/Actions/artistActions.js b/frontend/src/Store/Actions/artistActions.js index 97b30972b..685b372ae 100644 --- a/frontend/src/Store/Actions/artistActions.js +++ b/frontend/src/Store/Actions/artistActions.js @@ -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 + })); + }); + } }); // diff --git a/frontend/src/Store/Actions/artistEditorActions.js b/frontend/src/Store/Actions/artistEditorActions.js index 2e3d5d1f7..15bd8f542 100644 --- a/frontend/src/Store/Actions/artistEditorActions.js +++ b/frontend/src/Store/Actions/artistEditorActions.js @@ -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 }); }), diff --git a/frontend/src/Styles/Themes/dark.js b/frontend/src/Styles/Themes/dark.js index 5e16185ca..7513139a4 100644 --- a/frontend/src/Styles/Themes/dark.js +++ b/frontend/src/Styles/Themes/dark.js @@ -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', diff --git a/frontend/src/Styles/Themes/light.js b/frontend/src/Styles/Themes/light.js index b7d24f92a..ccf5dcea6 100644 --- a/frontend/src/Styles/Themes/light.js +++ b/frontend/src/Styles/Themes/light.js @@ -39,7 +39,7 @@ module.exports = { themeDarkColor: '#353535', themeLightColor: '#1d563d', pageBackground: '#f5f7fa', - pageFooterBackgroud: '#f1f1f1', + pageFooterBackground: '#f1f1f1', torrentColor: '#00853d', usenetColor: '#17b1d9', diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 84f7161e6..62e303cf9 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -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.",