New: Album Studio is now part of artists list

(cherry picked from commit bdcfef80d627e777d7932c54cda04cbe7c656ffc)

Load albums for album details on mouse hover
This commit is contained in:
Mark McDowall 2023-01-26 20:26:12 -08:00 committed by Bogdan
commit 72267d3cb4
25 changed files with 591 additions and 18 deletions

View file

@ -1,4 +1,5 @@
import ModelBase from 'App/ModelBase';
import Artist from 'Artist/Artist';
export interface Statistics {
trackCount: number;
@ -9,13 +10,16 @@ export interface Statistics {
}
interface Album extends ModelBase {
artist: Artist;
foreignAlbumId: string;
title: string;
overview: string;
disambiguation?: string;
albumType: string;
monitored: boolean;
releaseDate: string;
statistics: Statistics;
isSaving?: boolean;
}
export default Album;

View file

@ -0,0 +1,8 @@
import Album from 'Album/Album';
import AppSectionState, {
AppSectionDeleteState,
} from 'App/State/AppSectionState';
interface AlbumAppState extends AppSectionState<Album>, AppSectionDeleteState {}
export default AlbumAppState;

View file

@ -1,3 +1,4 @@
import AlbumAppState from './AlbumAppState';
import ArtistAppState, { ArtistIndexAppState } from './ArtistAppState';
import SettingsAppState from './SettingsAppState';
import TagsAppState from './TagsAppState';
@ -35,6 +36,7 @@ export interface CustomFilter {
}
interface AppState {
albums: AlbumAppState;
artist: ArtistAppState;
artistIndex: ArtistIndexAppState;
settings: SettingsAppState;

View file

@ -23,6 +23,7 @@ export interface Ratings {
interface Artist extends ModelBase {
added: string;
artistMetadataId: string;
foreignArtistId: string;
cleanName: string;
ended: boolean;
@ -37,7 +38,6 @@ interface Artist extends ModelBase {
metadataProfileId: number;
ratings: Ratings;
rootFolderPath: string;
albums: Album[];
sortName: string;
statistics: Statistics;
status: string;

View file

@ -0,0 +1,10 @@
.albums {
display: flex;
flex-wrap: wrap;
}
.truncated {
align-self: center;
flex: 0 0 100%;
padding: 4px 6px;
}

View file

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

View file

@ -0,0 +1,88 @@
import _ from 'lodash';
import React, { useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { kinds } from 'Helpers/Props';
import { clearAlbums, fetchAlbums } from 'Store/Actions/albumActions';
import createArtistAlbumsSelector from 'Store/Selectors/createArtistAlbumsSelector';
import translate from 'Utilities/String/translate';
import AlbumStudioAlbum from './AlbumStudioAlbum';
import styles from './AlbumDetails.css';
interface AlbumDetailsProps {
artistId: number;
}
function AlbumDetails(props: AlbumDetailsProps) {
const { artistId } = props;
const {
isFetching,
isPopulated,
error,
items: albums,
} = useSelector(createArtistAlbumsSelector(artistId));
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchAlbums({ artistId }));
return () => {
dispatch(clearAlbums());
};
}, [dispatch, artistId]);
const latestAlbums = useMemo(() => {
const sortedAlbums = _.orderBy(albums, 'releaseDate', 'desc');
return sortedAlbums.slice(0, 20);
}, [albums]);
return (
<div className={styles.albums}>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<Alert kind={kinds.DANGER}>{translate('AlbumsLoadError')}</Alert>
) : null}
{isPopulated && !error
? latestAlbums.map((album) => {
const {
id: albumId,
title,
disambiguation,
albumType,
monitored,
statistics,
isSaving,
} = album;
return (
<AlbumStudioAlbum
key={albumId}
artistId={artistId}
albumId={albumId}
title={title}
disambiguation={disambiguation}
albumType={albumType}
monitored={monitored}
statistics={statistics}
isSaving={isSaving}
/>
);
})
: null}
{latestAlbums.length < albums.length ? (
<div className={styles.truncated}>
{translate('AlbumStudioTruncated')}
</div>
) : null}
</div>
);
}
export default AlbumDetails;

View file

@ -0,0 +1,39 @@
.album {
display: flex;
align-items: stretch;
overflow: hidden;
margin: 2px 4px;
border: 1px solid var(--borderColor);
border-radius: 4px;
background-color: var(--albumBackgroundColor);
cursor: default;
}
.info {
padding: 0 4px;
}
.albumType {
padding: 0 4px;
border-width: 0 1px;
border-style: solid;
border-color: var(--borderColor);
background-color: var(--albumBackgroundColor);
color: var(--defaultColor);
}
.tracks {
padding: 0 4px;
background-color: var(--trackBackgroundColor);
color: var(--defaultColor);
}
.allTracks {
background-color: color(#27c24c saturation(-25%));
color: var(--white);
}
.missingWanted {
background-color: color(#f05050 saturation(-20%));
color: var(--white);
}

View file

@ -0,0 +1,12 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'album': string;
'albumType': string;
'allTracks': string;
'info': string;
'missingWanted': string;
'tracks': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -0,0 +1,83 @@
import classNames from 'classnames';
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { Statistics } from 'Album/Album';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import { toggleAlbumsMonitored } from 'Store/Actions/albumActions';
import translate from 'Utilities/String/translate';
import styles from './AlbumStudioAlbum.css';
interface AlbumStudioAlbumProps {
artistId: number;
albumId: number;
title: string;
disambiguation: string;
albumType: string;
monitored: boolean;
statistics: Statistics;
isSaving: boolean;
}
function AlbumStudioAlbum(props: AlbumStudioAlbumProps) {
const {
albumId,
title,
disambiguation,
albumType,
monitored,
statistics = {
trackFileCount: 0,
totalTrackCount: 0,
percentOfTracks: 0,
},
isSaving = false,
} = props;
const { trackFileCount, totalTrackCount, percentOfTracks } = statistics;
const dispatch = useDispatch();
const onAlbumMonitoredPress = useCallback(() => {
dispatch(
toggleAlbumsMonitored({
albumIds: [albumId],
monitored: !monitored,
})
);
}, [albumId, monitored, dispatch]);
return (
<div className={styles.album}>
<div className={styles.info}>
<MonitorToggleButton
monitored={monitored}
isSaving={isSaving}
onPress={onAlbumMonitoredPress}
/>
<span>
{disambiguation ? `${title} (${disambiguation})` : `${title}`}
</span>
</div>
<div className={styles.albumType}>
<span>{albumType}</span>
</div>
<div
className={classNames(
styles.tracks,
percentOfTracks < 100 && monitored && styles.missingWanted,
percentOfTracks === 100 && styles.allTracks
)}
title={translate('AlbumStudioTracksDownloaded', {
trackFileCount,
totalTrackCount,
})}
>
{totalTrackCount === 0 ? '0/0' : `${trackFileCount}/${totalTrackCount}`}
</div>
</div>
);
}
export default AlbumStudioAlbum;

View file

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

View file

@ -0,0 +1,26 @@
.labelIcon {
margin-left: 8px;
}
.message {
composes: alert from '~Components/Alert.css';
margin-bottom: 30px;
}
.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;
}
}

View file

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

View file

@ -0,0 +1,96 @@
import React, { useCallback, useState } from 'react';
import ArtistMonitoringOptionsPopoverContent from 'AddArtist/ArtistMonitoringOptionsPopoverContent';
import Alert from 'Components/Alert';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import 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 Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './ChangeMonitoringModalContent.css';
const NO_CHANGE = 'noChange';
interface ChangeMonitoringModalContentProps {
artistIds: number[];
saveError?: object;
onSavePress(monitor: string): void;
onModalClose(): void;
}
function ChangeMonitoringModalContent(
props: ChangeMonitoringModalContentProps
) {
const { artistIds, onSavePress, onModalClose, ...otherProps } = props;
const [monitor, setMonitor] = useState(NO_CHANGE);
const onInputChange = useCallback(
({ value }) => {
setMonitor(value);
},
[setMonitor]
);
const onSavePressWrapper = useCallback(() => {
onSavePress(monitor);
}, [monitor, onSavePress]);
const selectedCount = artistIds.length;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('MonitorArtists')}</ModalHeader>
<ModalBody>
<Alert kind={kinds.INFO} className={styles.message}>
{translate('MonitorAlbumExistingOnlyWarning')}
</Alert>
<Form {...otherProps}>
<FormGroup>
<FormLabel>
{translate('MonitorExistingAlbums')}
<Popover
anchor={<Icon className={styles.labelIcon} name={icons.INFO} />}
title={translate('MonitoringOptions')}
body={<ArtistMonitoringOptionsPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.MONITOR_ALBUMS_SELECT}
name="monitor"
value={monitor}
includeNoChange={true}
onChange={onInputChange}
/>
</FormGroup>
</Form>
</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('Save')}</Button>
</div>
</ModalFooter>
</ModalContent>
);
}
export default ChangeMonitoringModalContent;

View file

@ -51,6 +51,10 @@
gap: 20px;
}
.actionButtons {
flex-wrap: wrap;
}
.actionButtons,
.deleteButtons {
display: flex;

View file

@ -2,15 +2,20 @@ 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 AppState from 'App/State/AppState';
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 {
saveArtistEditor,
updateArtistsMonitor,
} 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 ChangeMonitoringModal from './AlbumStudio/ChangeMonitoringModal';
import RetagArtistModal from './AudioTags/RetagArtistModal';
import DeleteArtistModal from './Delete/DeleteArtistModal';
import EditArtistModal from './Edit/EditArtistModal';
@ -19,7 +24,7 @@ import TagsModal from './Tags/TagsModal';
import styles from './ArtistIndexSelectFooter.css';
const artistEditorSelector = createSelector(
(state) => state.artist,
(state: AppState) => state.artist,
(artist) => {
const { isSaving, isDeleting, deleteError } = artist;
@ -48,9 +53,11 @@ function ArtistIndexSelectFooter() {
const [isOrganizeModalOpen, setIsOrganizeModalOpen] = useState(false);
const [isRetaggingModalOpen, setIsRetaggingModalOpen] = useState(false);
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false);
const [isMonitoringModalOpen, setIsMonitoringModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isSavingArtist, setIsSavingArtist] = useState(false);
const [isSavingTags, setIsSavingTags] = useState(false);
const [isSavingMonitoring, setIsSavingMonitoring] = useState(false);
const [selectState, selectDispatch] = useSelect();
const { selectedState } = selectState;
@ -124,6 +131,29 @@ function ArtistIndexSelectFooter() {
[artistIds, dispatch]
);
const onMonitoringPress = useCallback(() => {
setIsMonitoringModalOpen(true);
}, [setIsMonitoringModalOpen]);
const onMonitoringClose = useCallback(() => {
setIsMonitoringModalOpen(false);
}, [setIsMonitoringModalOpen]);
const onMonitoringSavePress = useCallback(
(monitor: string) => {
setIsSavingMonitoring(true);
setIsMonitoringModalOpen(false);
dispatch(
updateArtistsMonitor({
artistIds,
monitor,
})
);
},
[artistIds, dispatch]
);
const onDeletePress = useCallback(() => {
setIsDeleteModalOpen(true);
}, [setIsDeleteModalOpen]);
@ -136,6 +166,7 @@ function ArtistIndexSelectFooter() {
if (!isSaving) {
setIsSavingArtist(false);
setIsSavingTags(false);
setIsSavingMonitoring(false);
}
}, [isSaving]);
@ -188,6 +219,14 @@ function ArtistIndexSelectFooter() {
>
{translate('SetAppTags')}
</SpinnerButton>
<SpinnerButton
isSpinning={isSaving && isSavingMonitoring}
isDisabled={!anySelected || isOrganizingArtist || isRetaggingArtist}
onPress={onMonitoringPress}
>
{translate('UpdateMonitoring')}
</SpinnerButton>
</div>
<div className={styles.deleteButtons}>
@ -220,6 +259,13 @@ function ArtistIndexSelectFooter() {
onModalClose={onTagsModalClose}
/>
<ChangeMonitoringModal
isOpen={isMonitoringModalOpen}
artistIds={artistIds}
onSavePress={onMonitoringSavePress}
onModalClose={onMonitoringClose}
/>
<OrganizeArtistModal
isOpen={isOrganizeModalOpen}
artistIds={artistIds}

View file

@ -0,0 +1,4 @@
.albumCount {
width: 100%;
cursor: default;
}

View file

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

View file

@ -0,0 +1,38 @@
import React from 'react';
import AlbumDetails from 'Artist/Index/Select/AlbumStudio/AlbumDetails';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import Popover from 'Components/Tooltip/Popover';
import TooltipPosition from 'Helpers/Props/TooltipPosition';
import translate from 'Utilities/String/translate';
import styles from './AlbumsCell.css';
interface SeriesStatusCellProps {
className: string;
artistId: number;
albumCount: number;
isSelectMode: boolean;
}
function AlbumsCell(props: SeriesStatusCellProps) {
const { className, artistId, albumCount, isSelectMode, ...otherProps } =
props;
return (
<VirtualTableRowCell className={className} {...otherProps}>
{isSelectMode && albumCount > 0 ? (
<Popover
className={styles.albumCount}
anchor={albumCount}
title={translate('AlbumDetails')}
body={<AlbumDetails artistId={artistId} />}
position={TooltipPosition.Left}
canFlip={true}
/>
) : (
albumCount
)}
</VirtualTableRowCell>
);
}
export default AlbumsCell;

View file

@ -26,6 +26,7 @@ import { executeCommand } from 'Store/Actions/commandActions';
import getProgressBarKind from 'Utilities/Artist/getProgressBarKind';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import AlbumsCell from './AlbumsCell';
import hasGrowableColumns from './hasGrowableColumns';
import selectTableOptions from './selectTableOptions';
import styles from './ArtistIndexRow.css';
@ -164,6 +165,7 @@ function ArtistIndexRow(props: ArtistIndexRowProps) {
artistType={artistType}
monitored={monitored}
status={status}
isSelectMode={isSelectMode}
isSaving={isSaving}
component={VirtualTableRowCell}
/>
@ -288,9 +290,13 @@ function ArtistIndexRow(props: ArtistIndexRowProps) {
if (name === 'albumCount') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{albumCount}
</VirtualTableRowCell>
<AlbumsCell
key={name}
className={styles[name]}
artistId={artistId}
albumCount={albumCount}
isSelectMode={isSelectMode}
/>
);
}

View file

@ -14,6 +14,7 @@ interface ArtistStatusCellProps {
artistType?: string;
monitored: boolean;
status: string;
isSelectMode: boolean;
isSaving: boolean;
component?: React.ElementType;
}
@ -25,12 +26,14 @@ function ArtistStatusCell(props: ArtistStatusCellProps) {
artistType,
monitored,
status,
isSelectMode,
isSaving,
component: Component = VirtualTableRowCell,
...otherProps
} = props;
const endedString = artistType === 'Person' ? 'Deceased' : 'Inactive';
const endedString =
artistType === 'Person' ? translate('Deceased') : translate('Inactive');
const dispatch = useDispatch();
const onMonitoredPress = useCallback(() => {
@ -39,13 +42,24 @@ function ArtistStatusCell(props: ArtistStatusCellProps) {
return (
<Component className={className} {...otherProps}>
<MonitorToggleButton
className={styles.monitorToggle}
monitored={monitored}
size={14}
isSaving={isSaving}
onPress={onMonitoredPress}
/>
{isSelectMode ? (
<MonitorToggleButton
className={styles.statusIcon}
monitored={monitored}
isSaving={isSaving}
onPress={onMonitoredPress}
/>
) : (
<Icon
className={styles.statusIcon}
name={monitored ? icons.MONITORED : icons.UNMONITORED}
title={
monitored
? translate('ArtistIsMonitored')
: translate('ArtistIsUnmonitored')
}
/>
)}
<Icon
className={styles.statusIcon}

View file

@ -47,7 +47,8 @@ class MonitoringOptionsModalContentConnector extends Component {
onSavePress = ({ monitor }) => {
this.props.dispatchUpdateMonitoringOptions({
artistIds: [this.props.artistId],
monitor
monitor,
shouldFetchAlbumsAfterUpdate: true
});
};

View file

@ -358,7 +358,8 @@ export const actionHandlers = handleThunks({
artistIds,
monitor,
monitored,
monitorNewItems
monitorNewItems,
shouldFetchAlbumsAfterUpdate = false
} = payload;
const artists = [];
@ -390,7 +391,9 @@ export const actionHandlers = handleThunks({
}).request;
promise.done((data) => {
dispatch(fetchAlbums({ artistId: artistIds[0] }));
if (shouldFetchAlbumsAfterUpdate) {
dispatch(fetchAlbums({ artistId: artistIds[0] }));
}
dispatch(set({
section,

View file

@ -0,0 +1,27 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Artist from 'Artist/Artist';
import { createArtistSelectorForHook } from './createArtistSelector';
function createArtistAlbumsSelector(artistId: number) {
return createSelector(
(state: AppState) => state.albums,
createArtistSelectorForHook(artistId),
(albums, artist = {} as Artist) => {
const { isFetching, isPopulated, error, items } = albums;
const filteredAlbums = items.filter(
(album) => album.artist.artistMetadataId === artist.artistMetadataId
);
return {
isFetching,
isPopulated,
error,
items: filteredAlbums,
};
}
);
}
export default createArtistAlbumsSelector;