Refactor Artist index to use react-window

(cherry picked from commit d022679b7dcbce3cec98e6a1fd0879e3c0d92523)

Fixed: Restoring scroll position when going back/forward to artist list

(cherry picked from commit 5aad84dba453c42b4b5a9eac43deecf91a98f4f6)
pull/4223/head
Mark McDowall 1 year ago committed by Bogdan
parent 01e21c09db
commit f509ca0f72

@ -0,0 +1,21 @@
import ModelBase from 'App/ModelBase';
export interface Statistics {
trackCount: number;
trackFileCount: number;
percentOfTracks: number;
sizeOnDisk: number;
totalTrackCount: number;
}
interface Album extends ModelBase {
foreignAlbumId: string;
title: string;
overview: string;
disambiguation?: string;
monitored: boolean;
releaseDate: string;
statistics: Statistics;
}
export default Album;

@ -8,7 +8,7 @@ import AlbumDetailsPageConnector from 'Album/Details/AlbumDetailsPageConnector';
import AlbumStudioConnector from 'AlbumStudio/AlbumStudioConnector';
import ArtistDetailsPageConnector from 'Artist/Details/ArtistDetailsPageConnector';
import ArtistEditorConnector from 'Artist/Editor/ArtistEditorConnector';
import ArtistIndexConnector from 'Artist/Index/ArtistIndexConnector';
import ArtistIndex from 'Artist/Index/ArtistIndex';
import CalendarPageConnector from 'Calendar/CalendarPageConnector';
import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch';
@ -51,7 +51,7 @@ function AppRoutes(props) {
<Route
exact={true}
path="/"
component={ArtistIndexConnector}
component={ArtistIndex}
/>
{

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

@ -0,0 +1,72 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
import Artist from 'Artist/Artist';
import Column from 'Components/Table/Column';
import SortDirection from 'Helpers/Props/SortDirection';
import { Filter, FilterBuilderProp } from './AppState';
export interface ArtistIndexAppState {
sortKey: string;
sortDirection: SortDirection;
secondarySortKey: string;
secondarySortDirection: SortDirection;
view: string;
posterOptions: {
detailedProgressBar: boolean;
size: string;
showTitle: boolean;
showMonitored: boolean;
showQualityProfile: boolean;
showNextAlbum: boolean;
showSearchAction: boolean;
};
bannerOptions: {
detailedProgressBar: boolean;
size: string;
showTitle: boolean;
showMonitored: boolean;
showQualityProfile: boolean;
showNextAlbum: boolean;
showSearchAction: boolean;
};
overviewOptions: {
detailedProgressBar: boolean;
size: string;
showMonitored: boolean;
showQualityProfile: boolean;
showLastAlbum: boolean;
showAdded: boolean;
showAlbumCount: boolean;
showPath: boolean;
showSizeOnDisk: boolean;
showSearchAction: boolean;
};
tableOptions: {
showBanners: boolean;
showSearchAction: boolean;
};
selectedFilterKey: string;
filterBuilderProps: FilterBuilderProp<Artist>[];
filters: Filter[];
columns: Column[];
}
interface ArtistAppState
extends AppSectionState<Artist>,
AppSectionDeleteState,
AppSectionSaveState {
itemMap: Record<number, number>;
deleteOptions: {
addImportListExclusion: boolean;
};
}
export default ArtistAppState;

@ -1,11 +1,14 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
AppSectionSchemaState,
} from 'App/State/AppSectionState';
import DownloadClient from 'typings/DownloadClient';
import ImportList from 'typings/ImportList';
import Indexer from 'typings/Indexer';
import MetadataProfile from 'typings/MetadataProfile';
import Notification from 'typings/Notification';
import QualityProfile from 'typings/QualityProfile';
import { UiSettings } from 'typings/UiSettings';
export interface DownloadClientAppState
@ -27,13 +30,23 @@ export interface NotificationAppState
extends AppSectionState<Notification>,
AppSectionDeleteState {}
export interface QualityProfilesAppState
extends AppSectionState<QualityProfile>,
AppSectionSchemaState<QualityProfile> {}
export interface MetadataProfilesAppState
extends AppSectionState<MetadataProfile>,
AppSectionSchemaState<MetadataProfile> {}
export type UiSettingsAppState = AppSectionState<UiSettings>;
interface SettingsAppState {
downloadClients: DownloadClientAppState;
importLists: ImportListAppState;
indexers: IndexerAppState;
metadataProfiles: MetadataProfilesAppState;
notifications: NotificationAppState;
qualityProfiles: QualityProfilesAppState;
uiSettings: UiSettingsAppState;
}

@ -0,0 +1,51 @@
import Album from 'Album/Album';
import ModelBase from 'App/ModelBase';
export interface Image {
coverType: string;
url: string;
remoteUrl: string;
}
export interface Statistics {
albumCount: number;
trackCount: number;
trackFileCount: number;
percentOfTracks: number;
sizeOnDisk: number;
totalTrackCount: number;
}
export interface Ratings {
votes: number;
value: number;
}
interface Artist extends ModelBase {
added: string;
foreignArtistId: string;
cleanName: string;
ended: boolean;
genres: string[];
images: Image[];
monitored: boolean;
overview: string;
path: string;
lastAlbum?: Album;
nextAlbum?: Album;
qualityProfileId: number;
metadataProfileId: number;
ratings: Ratings;
rootFolderPath: string;
albums: Album[];
sortName: string;
statistics: Statistics;
status: string;
tags: number[];
artistName: string;
artistType?: string;
disambiguation?: string;
isSaving?: boolean;
}
export default Artist;

@ -15,6 +15,10 @@ function ArtistBanner(props) {
}
ArtistBanner.propTypes = {
...ArtistImage.propTypes,
coverType: PropTypes.string,
placeholder: PropTypes.string,
overflow: PropTypes.bool,
size: PropTypes.number.isRequired
};

@ -15,6 +15,10 @@ function ArtistPoster(props) {
}
ArtistPoster.propTypes = {
...ArtistImage.propTypes,
coverType: PropTypes.string,
placeholder: PropTypes.string,
overflow: PropTypes.bool,
size: PropTypes.number.isRequired
};

@ -26,6 +26,7 @@ function DeleteArtistModal(props) {
}
DeleteArtistModal.propTypes = {
...DeleteArtistModalContentConnector.propTypes,
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};

@ -18,6 +18,7 @@ function EditArtistModal({ isOpen, onModalClose, ...otherProps }) {
}
EditArtistModal.propTypes = {
...EditArtistModalContentConnector.propTypes,
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};

@ -32,6 +32,7 @@ class EditArtistModalConnector extends Component {
}
EditArtistModalConnector.propTypes = {
...EditArtistModal.propTypes,
onModalClose: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};

@ -1,407 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import NoArtist from 'Artist/NoArtist';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageJumpBar from 'Components/Page/PageJumpBar';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import { align, icons, sortDirections } from 'Helpers/Props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder';
import translate from 'Utilities/String/translate';
import ArtistIndexFooterConnector from './ArtistIndexFooterConnector';
import ArtistIndexBannersConnector from './Banners/ArtistIndexBannersConnector';
import ArtistIndexBannerOptionsModal from './Banners/Options/ArtistIndexBannerOptionsModal';
import ArtistIndexFilterMenu from './Menus/ArtistIndexFilterMenu';
import ArtistIndexSortMenu from './Menus/ArtistIndexSortMenu';
import ArtistIndexViewMenu from './Menus/ArtistIndexViewMenu';
import ArtistIndexOverviewsConnector from './Overview/ArtistIndexOverviewsConnector';
import ArtistIndexOverviewOptionsModal from './Overview/Options/ArtistIndexOverviewOptionsModal';
import ArtistIndexPostersConnector from './Posters/ArtistIndexPostersConnector';
import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal';
import ArtistIndexTableConnector from './Table/ArtistIndexTableConnector';
import ArtistIndexTableOptionsConnector from './Table/ArtistIndexTableOptionsConnector';
import styles from './ArtistIndex.css';
function getViewComponent(view) {
if (view === 'posters') {
return ArtistIndexPostersConnector;
}
if (view === 'banners') {
return ArtistIndexBannersConnector;
}
if (view === 'overview') {
return ArtistIndexOverviewsConnector;
}
return ArtistIndexTableConnector;
}
class ArtistIndex extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
scroller: null,
jumpBarItems: { order: [] },
jumpToCharacter: null,
isPosterOptionsModalOpen: false,
isBannerOptionsModalOpen: false,
isOverviewOptionsModalOpen: false
};
}
componentDidMount() {
this.setJumpBarItems();
}
componentDidUpdate(prevProps) {
const {
items,
sortKey,
sortDirection
} = this.props;
if (sortKey !== prevProps.sortKey ||
sortDirection !== prevProps.sortDirection ||
hasDifferentItemsOrOrder(prevProps.items, items)
) {
this.setJumpBarItems();
}
if (this.state.jumpToCharacter != null) {
this.setState({ jumpToCharacter: null });
}
}
//
// Control
setScrollerRef = (ref) => {
this.setState({ scroller: ref });
};
setJumpBarItems() {
const {
items,
sortKey,
sortDirection
} = this.props;
// Reset if not sorting by sortName
if (sortKey !== 'sortName') {
this.setState({ jumpBarItems: { order: [] } });
return;
}
const characters = _.reduce(items, (acc, item) => {
let char = item.sortName.charAt(0);
if (!isNaN(char)) {
char = '#';
}
if (char in acc) {
acc[char] = acc[char] + 1;
} else {
acc[char] = 1;
}
return acc;
}, {});
const order = Object.keys(characters).sort();
// Reverse if sorting descending
if (sortDirection === sortDirections.DESCENDING) {
order.reverse();
}
const jumpBarItems = {
characters,
order
};
this.setState({ jumpBarItems });
}
//
// Listeners
onPosterOptionsPress = () => {
this.setState({ isPosterOptionsModalOpen: true });
};
onPosterOptionsModalClose = () => {
this.setState({ isPosterOptionsModalOpen: false });
};
onBannerOptionsPress = () => {
this.setState({ isBannerOptionsModalOpen: true });
};
onBannerOptionsModalClose = () => {
this.setState({ isBannerOptionsModalOpen: false });
};
onOverviewOptionsPress = () => {
this.setState({ isOverviewOptionsModalOpen: true });
};
onOverviewOptionsModalClose = () => {
this.setState({ isOverviewOptionsModalOpen: false });
};
onJumpBarItemPress = (jumpToCharacter) => {
this.setState({ jumpToCharacter });
};
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
totalItems,
items,
columns,
selectedFilterKey,
filters,
customFilters,
sortKey,
sortDirection,
view,
isRefreshingArtist,
isRssSyncExecuting,
onScroll,
onSortSelect,
onFilterSelect,
onViewSelect,
onRefreshArtistPress,
onRssSyncPress,
...otherProps
} = this.props;
const {
scroller,
jumpBarItems,
jumpToCharacter,
isPosterOptionsModalOpen,
isBannerOptionsModalOpen,
isOverviewOptionsModalOpen
} = this.state;
const ViewComponent = getViewComponent(view);
const isLoaded = !!(!error && isPopulated && items.length && scroller);
const hasNoArtist = !totalItems;
return (
<PageContent>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('UpdateAll')}
iconName={icons.REFRESH}
spinningName={icons.REFRESH}
isSpinning={isRefreshingArtist}
onPress={onRefreshArtistPress}
/>
<PageToolbarButton
label={translate('RSSSync')}
iconName={icons.RSS}
isSpinning={isRssSyncExecuting}
isDisabled={hasNoArtist}
onPress={onRssSyncPress}
/>
</PageToolbarSection>
<PageToolbarSection
alignContent={align.RIGHT}
collapseButtons={false}
>
{
view === 'table' ?
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
optionsComponent={ArtistIndexTableOptionsConnector}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper> :
null
}
{
view === 'posters' ?
<PageToolbarButton
label={translate('Options')}
iconName={icons.POSTER}
isDisabled={hasNoArtist}
onPress={this.onPosterOptionsPress}
/> :
null
}
{
view === 'banners' ?
<PageToolbarButton
label={translate('Options')}
iconName={icons.POSTER}
isDisabled={hasNoArtist}
onPress={this.onBannerOptionsPress}
/> :
null
}
{
view === 'overview' ?
<PageToolbarButton
label={translate('Options')}
iconName={icons.OVERVIEW}
isDisabled={hasNoArtist}
onPress={this.onOverviewOptionsPress}
/> :
null
}
<PageToolbarSeparator />
<ArtistIndexViewMenu
view={view}
isDisabled={hasNoArtist}
onViewSelect={onViewSelect}
/>
<ArtistIndexSortMenu
sortKey={sortKey}
sortDirection={sortDirection}
isDisabled={hasNoArtist}
onSortSelect={onSortSelect}
/>
<ArtistIndexFilterMenu
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
isDisabled={hasNoArtist}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<div className={styles.pageContentBodyWrapper}>
<PageContentBody
registerScroller={this.setScrollerRef}
className={styles.contentBody}
innerClassName={styles[`${view}InnerContentBody`]}
onScroll={onScroll}
>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div className={styles.errorMessage}>
{getErrorMessage(error, 'Failed to load artist from API')}
</div>
}
{
isLoaded &&
<div className={styles.contentBodyContainer}>
<ViewComponent
scroller={scroller}
items={items}
filters={filters}
sortKey={sortKey}
sortDirection={sortDirection}
jumpToCharacter={jumpToCharacter}
{...otherProps}
/>
<ArtistIndexFooterConnector />
</div>
}
{
!error && isPopulated && !items.length &&
<NoArtist totalItems={totalItems} />
}
</PageContentBody>
{
isLoaded && !!jumpBarItems.order.length &&
<PageJumpBar
items={jumpBarItems}
onItemPress={this.onJumpBarItemPress}
/>
}
</div>
<ArtistIndexPosterOptionsModal
isOpen={isPosterOptionsModalOpen}
onModalClose={this.onPosterOptionsModalClose}
/>
<ArtistIndexBannerOptionsModal
isOpen={isBannerOptionsModalOpen}
onModalClose={this.onBannerOptionsModalClose}
/>
<ArtistIndexOverviewOptionsModal
isOpen={isOverviewOptionsModalOpen}
onModalClose={this.onOverviewOptionsModalClose}
/>
</PageContent>
);
}
}
ArtistIndex.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
totalItems: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
view: PropTypes.string.isRequired,
isRefreshingArtist: PropTypes.bool.isRequired,
isRssSyncExecuting: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
onSortSelect: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired,
onViewSelect: PropTypes.func.isRequired,
onRefreshArtistPress: PropTypes.func.isRequired,
onRssSyncPress: PropTypes.func.isRequired,
onScroll: PropTypes.func.isRequired
};
export default ArtistIndex;

@ -0,0 +1,333 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import NoArtist from 'Artist/NoArtist';
import { REFRESH_ARTIST, RSS_SYNC } from 'Commands/commandNames';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageJumpBar from 'Components/Page/PageJumpBar';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import withScrollPosition from 'Components/withScrollPosition';
import { align, icons } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import {
setArtistFilter,
setArtistSort,
setArtistTableOption,
setArtistView,
} from 'Store/Actions/artistIndexActions';
import { executeCommand } from 'Store/Actions/commandActions';
import scrollPositions from 'Store/scrollPositions';
import createArtistClientSideCollectionItemsSelector from 'Store/Selectors/createArtistClientSideCollectionItemsSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import ArtistIndexFooter from './ArtistIndexFooter';
import ArtistIndexBanners from './Banners/ArtistIndexBanners';
import ArtistIndexBannerOptionsModal from './Banners/Options/ArtistIndexBannerOptionsModal';
import ArtistIndexFilterMenu from './Menus/ArtistIndexFilterMenu';
import ArtistIndexSortMenu from './Menus/ArtistIndexSortMenu';
import ArtistIndexViewMenu from './Menus/ArtistIndexViewMenu';
import ArtistIndexOverviews from './Overview/ArtistIndexOverviews';
import ArtistIndexOverviewOptionsModal from './Overview/Options/ArtistIndexOverviewOptionsModal';
import ArtistIndexPosters from './Posters/ArtistIndexPosters';
import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal';
import ArtistIndexTable from './Table/ArtistIndexTable';
import ArtistIndexTableOptions from './Table/ArtistIndexTableOptions';
import styles from './ArtistIndex.css';
function getViewComponent(view) {
if (view === 'posters') {
return ArtistIndexPosters;
}
if (view === 'banners') {
return ArtistIndexBanners;
}
if (view === 'overview') {
return ArtistIndexOverviews;
}
return ArtistIndexTable;
}
interface ArtistIndexProps {
initialScrollTop?: number;
}
const ArtistIndex = withScrollPosition((props: ArtistIndexProps) => {
const {
isFetching,
isPopulated,
error,
totalItems,
items,
columns,
selectedFilterKey,
filters,
customFilters,
sortKey,
sortDirection,
view,
} = useSelector(createArtistClientSideCollectionItemsSelector('artistIndex'));
const isRefreshingArtist = useSelector(
createCommandExecutingSelector(REFRESH_ARTIST)
);
const isRssSyncExecuting = useSelector(
createCommandExecutingSelector(RSS_SYNC)
);
const { isSmallScreen } = useSelector(createDimensionsSelector());
const dispatch = useDispatch();
const scrollerRef = useRef<HTMLDivElement>();
const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false);
const [jumpToCharacter, setJumpToCharacter] = useState<string | null>(null);
const onRefreshArtistPress = useCallback(() => {
dispatch(
executeCommand({
name: REFRESH_ARTIST,
})
);
}, [dispatch]);
const onRssSyncPress = useCallback(() => {
dispatch(
executeCommand({
name: RSS_SYNC,
})
);
}, [dispatch]);
const onTableOptionChange = useCallback(
(payload) => {
dispatch(setArtistTableOption(payload));
},
[dispatch]
);
const onViewSelect = useCallback(
(value) => {
dispatch(setArtistView({ view: value }));
if (scrollerRef.current) {
scrollerRef.current.scrollTo(0, 0);
}
},
[scrollerRef, dispatch]
);
const onSortSelect = useCallback(
(value) => {
dispatch(setArtistSort({ sortKey: value }));
},
[dispatch]
);
const onFilterSelect = useCallback(
(value) => {
dispatch(setArtistFilter({ selectedFilterKey: value }));
},
[dispatch]
);
const onOptionsPress = useCallback(() => {
setIsOptionsModalOpen(true);
}, [setIsOptionsModalOpen]);
const onOptionsModalClose = useCallback(() => {
setIsOptionsModalOpen(false);
}, [setIsOptionsModalOpen]);
const onJumpBarItemPress = useCallback(
(character) => {
setJumpToCharacter(character);
},
[setJumpToCharacter]
);
const onScroll = useCallback(
({ scrollTop }) => {
setJumpToCharacter(null);
scrollPositions.artistIndex = scrollTop;
},
[setJumpToCharacter]
);
const jumpBarItems = useMemo(() => {
// Reset if not sorting by sortName
if (sortKey !== 'sortName') {
return {
order: [],
};
}
const characters = items.reduce((acc, item) => {
let char = item.sortName.charAt(0);
if (!isNaN(char)) {
char = '#';
}
if (char in acc) {
acc[char] = acc[char] + 1;
} else {
acc[char] = 1;
}
return acc;
}, {});
const order = Object.keys(characters).sort();
// Reverse if sorting descending
if (sortDirection === SortDirection.Descending) {
order.reverse();
}
return {
characters,
order,
};
}, [items, sortKey, sortDirection]);
const ViewComponent = useMemo(() => getViewComponent(view), [view]);
const isLoaded = !!(!error && isPopulated && items.length);
const hasNoArtist = !totalItems;
return (
<PageContent>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('UpdateAll')}
iconName={icons.REFRESH}
spinningName={icons.REFRESH}
isSpinning={isRefreshingArtist}
isDisabled={hasNoArtist}
onPress={onRefreshArtistPress}
/>
<PageToolbarButton
label={translate('RSSSync')}
iconName={icons.RSS}
isSpinning={isRssSyncExecuting}
isDisabled={hasNoArtist}
onPress={onRssSyncPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT} collapseButtons={false}>
{view === 'table' ? (
<TableOptionsModalWrapper
columns={columns}
optionsComponent={ArtistIndexTableOptions}
onTableOptionChange={onTableOptionChange}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
) : (
<PageToolbarButton
label={translate('Options')}
iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW}
isDisabled={hasNoArtist}
onPress={onOptionsPress}
/>
)}
<PageToolbarSeparator />
<ArtistIndexViewMenu
view={view}
isDisabled={hasNoArtist}
onViewSelect={onViewSelect}
/>
<ArtistIndexSortMenu
sortKey={sortKey}
sortDirection={sortDirection}
isDisabled={hasNoArtist}
onSortSelect={onSortSelect}
/>
<ArtistIndexFilterMenu
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
isDisabled={hasNoArtist}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<div className={styles.pageContentBodyWrapper}>
<PageContentBody
ref={scrollerRef}
className={styles.contentBody}
innerClassName={styles[`${view}InnerContentBody`]}
initialScrollTop={props.initialScrollTop}
onScroll={onScroll}
>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && !!error ? (
<div className={styles.errorMessage}>
{getErrorMessage(error, 'Failed to load artist from API')}
</div>
) : null}
{isLoaded ? (
<div className={styles.contentBodyContainer}>
<ViewComponent
scrollerRef={scrollerRef}
items={items}
sortKey={sortKey}
sortDirection={sortDirection}
jumpToCharacter={jumpToCharacter}
isSmallScreen={isSmallScreen}
/>
<ArtistIndexFooter />
</div>
) : null}
{!error && isPopulated && !items.length ? (
<NoArtist totalItems={totalItems} />
) : null}
</PageContentBody>
{isLoaded && !!jumpBarItems.order.length ? (
<PageJumpBar items={jumpBarItems} onItemPress={onJumpBarItemPress} />
) : null}
</div>
{view === 'posters' ? (
<ArtistIndexPosterOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
/>
) : null}
{view === 'banners' ? (
<ArtistIndexBannerOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
/>
) : null}
{view === 'overview' ? (
<ArtistIndexOverviewOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
/>
) : null}
</PageContent>
);
}, 'artistIndex');
export default ArtistIndex;

@ -1,106 +0,0 @@
/* eslint max-params: 0 */
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import withScrollPosition from 'Components/withScrollPosition';
import { setArtistFilter, setArtistSort, setArtistTableOption, setArtistView } from 'Store/Actions/artistIndexActions';
import { executeCommand } from 'Store/Actions/commandActions';
import scrollPositions from 'Store/scrollPositions';
import createArtistClientSideCollectionItemsSelector from 'Store/Selectors/createArtistClientSideCollectionItemsSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import ArtistIndex from './ArtistIndex';
function createMapStateToProps() {
return createSelector(
createArtistClientSideCollectionItemsSelector('artistIndex'),
createCommandExecutingSelector(commandNames.REFRESH_ARTIST),
createCommandExecutingSelector(commandNames.RSS_SYNC),
createDimensionsSelector(),
(
artist,
isRefreshingArtist,
isRssSyncExecuting,
dimensionsState
) => {
return {
...artist,
isRefreshingArtist,
isRssSyncExecuting,
isSmallScreen: dimensionsState.isSmallScreen
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onTableOptionChange(payload) {
dispatch(setArtistTableOption(payload));
},
onSortSelect(sortKey) {
dispatch(setArtistSort({ sortKey }));
},
onFilterSelect(selectedFilterKey) {
dispatch(setArtistFilter({ selectedFilterKey }));
},
dispatchSetArtistView(view) {
dispatch(setArtistView({ view }));
},
onRefreshArtistPress() {
dispatch(executeCommand({
name: commandNames.REFRESH_ARTIST
}));
},
onRssSyncPress() {
dispatch(executeCommand({
name: commandNames.RSS_SYNC
}));
}
};
}
class ArtistIndexConnector extends Component {
//
// Listeners
onViewSelect = (view) => {
this.props.dispatchSetArtistView(view);
};
onScroll = ({ scrollTop }) => {
scrollPositions.artistIndex = scrollTop;
};
//
// Render
render() {
return (
<ArtistIndex
{...this.props}
onViewSelect={this.onViewSelect}
onScroll={this.onScroll}
/>
);
}
}
ArtistIndexConnector.propTypes = {
isSmallScreen: PropTypes.bool.isRequired,
view: PropTypes.string.isRequired,
dispatchSetArtistView: PropTypes.func.isRequired
};
export default withScrollPosition(
connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexConnector),
'artistIndex'
);

@ -0,0 +1,49 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal from 'Components/Filter/FilterModal';
import { setArtistFilter } from 'Store/Actions/artistIndexActions';
function createArtistSelector() {
return createSelector(
(state: AppState) => state.artist.items,
(artist) => {
return artist;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.artistIndex.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
export default function ArtistIndexFilterModal(props) {
const sectionItems = useSelector(createArtistSelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'artist';
const dispatch = useDispatch();
const dispatchSetFilter = useCallback(
(payload) => {
dispatch(setArtistFilter(payload));
},
[dispatch]
);
return (
<FilterModal
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType={customFilterType}
dispatchSetFilter={dispatchSetFilter}
/>
);
}

@ -1,24 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import FilterModal from 'Components/Filter/FilterModal';
import { setArtistFilter } from 'Store/Actions/artistIndexActions';
function createMapStateToProps() {
return createSelector(
(state) => state.artist.items,
(state) => state.artistIndex.filterBuilderProps,
(sectionItems, filterBuilderProps) => {
return {
sectionItems,
filterBuilderProps,
customFilterType: 'artistIndex'
};
}
);
}
const mapDispatchToProps = {
dispatchSetFilter: setArtistFilter
};
export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal);

@ -1,167 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { PureComponent } from 'react';
import { ColorImpairedConsumer } from 'App/ColorImpairedContext';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import styles from './ArtistIndexFooter.css';
class ArtistIndexFooter extends PureComponent {
//
// Render
render() {
const { artist } = this.props;
const count = artist.length;
let tracks = 0;
let trackFiles = 0;
let ended = 0;
let continuing = 0;
let monitored = 0;
let totalFileSize = 0;
artist.forEach((s) => {
const { statistics = {} } = s;
const {
trackCount = 0,
trackFileCount = 0,
sizeOnDisk = 0
} = statistics;
tracks += trackCount;
trackFiles += trackFileCount;
if (s.status === 'ended') {
ended++;
} else {
continuing++;
}
if (s.monitored) {
monitored++;
}
totalFileSize += sizeOnDisk;
});
return (
<ColorImpairedConsumer>
{(enableColorImpairedMode) => {
return (
<div className={styles.footer}>
<div>
<div className={styles.legendItem}>
<div
className={classNames(
styles.continuing,
enableColorImpairedMode && 'colorImpaired'
)}
/>
<div>
{translate('ContinuingAllTracksDownloaded')}
</div>
</div>
<div className={styles.legendItem}>
<div
className={classNames(
styles.ended,
enableColorImpairedMode && 'colorImpaired'
)}
/>
<div>
{translate('EndedAllTracksDownloaded')}
</div>
</div>
<div className={styles.legendItem}>
<div
className={classNames(
styles.missingMonitored,
enableColorImpairedMode && 'colorImpaired'
)}
/>
<div>
{translate('MissingTracksArtistMonitored')}
</div>
</div>
<div className={styles.legendItem}>
<div
className={classNames(
styles.missingUnmonitored,
enableColorImpairedMode && 'colorImpaired'
)}
/>
<div>
{translate('MissingTracksArtistNotMonitored')}
</div>
</div>
</div>
<div className={styles.statistics}>
<DescriptionList>
<DescriptionListItem
title={translate('Artist')}
data={count}
/>
<DescriptionListItem
title={translate('Inactive')}
data={ended}
/>
<DescriptionListItem
title={translate('Continuing')}
data={continuing}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title={translate('Monitored')}
data={monitored}
/>
<DescriptionListItem
title={translate('Unmonitored')}
data={count - monitored}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title={translate('Tracks')}
data={tracks}
/>
<DescriptionListItem
title={translate('Files')}
data={trackFiles}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title={translate('TotalFileSize')}
data={formatBytes(totalFileSize)}
/>
</DescriptionList>
</div>
</div>
);
}}
</ColorImpairedConsumer>
);
}
}
ArtistIndexFooter.propTypes = {
artist: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default ArtistIndexFooter;

@ -0,0 +1,169 @@
import classNames from 'classnames';
import React from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { ColorImpairedConsumer } from 'App/ColorImpairedContext';
import ArtistAppState from 'App/State/ArtistAppState';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import styles from './ArtistIndexFooter.css';
function createUnoptimizedSelector() {
return createSelector(
createClientSideCollectionSelector('artist', 'artistIndex'),
(artist: ArtistAppState) => {
return artist.items.map((s) => {
const { monitored, status, statistics } = s;
return {
monitored,
status,
statistics,
};
});
}
);
}
function createArtistSelector() {
return createDeepEqualSelector(
createUnoptimizedSelector(),
(artist) => artist
);
}
export default function ArtistIndexFooter() {
const artist = useSelector(createArtistSelector());
const count = artist.length;
let tracks = 0;
let trackFiles = 0;
let ended = 0;
let continuing = 0;
let monitored = 0;
let totalFileSize = 0;
artist.forEach((a) => {
const { statistics = { trackCount: 0, trackFileCount: 0, sizeOnDisk: 0 } } =
a;
const { trackCount = 0, trackFileCount = 0, sizeOnDisk = 0 } = statistics;
tracks += trackCount;
trackFiles += trackFileCount;
if (a.status === 'ended') {
ended++;
} else {
continuing++;
}
if (a.monitored) {
monitored++;
}
totalFileSize += sizeOnDisk;
});
return (
<ColorImpairedConsumer>
{(enableColorImpairedMode) => {
return (
<div className={styles.footer}>
<div>
<div className={styles.legendItem}>
<div
className={classNames(
styles.continuing,
enableColorImpairedMode && 'colorImpaired'
)}
/>
<div>{translate('ContinuingAllTracksDownloaded')}</div>
</div>
<div className={styles.legendItem}>
<div
className={classNames(
styles.ended,
enableColorImpairedMode && 'colorImpaired'
)}
/>
<div>{translate('EndedAllTracksDownloaded')}</div>
</div>
<div className={styles.legendItem}>
<div
className={classNames(
styles.missingMonitored,
enableColorImpairedMode && 'colorImpaired'
)}
/>
<div>{translate('MissingTracksArtistMonitored')}</div>
</div>
<div className={styles.legendItem}>
<div
className={classNames(
styles.missingUnmonitored,
enableColorImpairedMode && 'colorImpaired'
)}
/>
<div>{translate('MissingTracksArtistNotMonitored')}</div>
</div>
</div>
<div className={styles.statistics}>
<DescriptionList>
<DescriptionListItem title={translate('Artist')} data={count} />
<DescriptionListItem
title={translate('Inactive')}
data={ended}
/>
<DescriptionListItem
title={translate('Continuing')}
data={continuing}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title={translate('Monitored')}
data={monitored}
/>
<DescriptionListItem
title={translate('Unmonitored')}
data={count - monitored}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title={translate('Tracks')}
data={tracks}
/>
<DescriptionListItem
title={translate('Files')}
data={trackFiles}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title={translate('TotalFileSize')}
data={formatBytes(totalFileSize)}
/>
</DescriptionList>
</div>
</div>
);
}}
</ColorImpairedConsumer>
);
}

@ -1,46 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector';
import ArtistIndexFooter from './ArtistIndexFooter';
function createUnoptimizedSelector() {
return createSelector(
createClientSideCollectionSelector('artist', 'artistIndex'),
(artist) => {
return artist.items.map((s) => {
const {
monitored,
status,
statistics
} = s;
return {
monitored,
status,
statistics
};
});
}
);
}
function createArtistSelector() {
return createDeepEqualSelector(
createUnoptimizedSelector(),
(artist) => artist
);
}
function createMapStateToProps() {
return createSelector(
createArtistSelector(),
(artist) => {
return {
artist
};
}
);
}
export default connect(createMapStateToProps)(ArtistIndexFooter);

@ -1,153 +0,0 @@
/* eslint max-params: 0 */
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import { toggleArtistMonitored } from 'Store/Actions/artistActions';
import { executeCommand } from 'Store/Actions/commandActions';
import createArtistMetadataProfileSelector from 'Store/Selectors/createArtistMetadataProfileSelector';
import createArtistQualityProfileSelector from 'Store/Selectors/createArtistQualityProfileSelector';
import createArtistSelector from 'Store/Selectors/createArtistSelector';
import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector';
function selectShowSearchAction() {
return createSelector(
(state) => state.artistIndex,
(artistIndex) => {
const view = artistIndex.view;
switch (view) {
case 'posters':
return artistIndex.posterOptions.showSearchAction;
case 'banners':
return artistIndex.bannerOptions.showSearchAction;
case 'overview':
return artistIndex.overviewOptions.showSearchAction;
default:
return artistIndex.tableOptions.showSearchAction;
}
}
);
}
function createMapStateToProps() {
return createSelector(
createArtistSelector(),
createArtistQualityProfileSelector(),
createArtistMetadataProfileSelector(),
selectShowSearchAction(),
createExecutingCommandsSelector(),
(
artist,
qualityProfile,
metadataProfile,
showSearchAction,
executingCommands
) => {
// If an artist is deleted this selector may fire before the parent
// selectors, which will result in an undefined artist, if that happens
// we want to return early here and again in the render function to avoid
// trying to show an artist that has no information available.
if (!artist) {
return {};
}
const isRefreshingArtist = executingCommands.some((command) => {
return (
command.name === commandNames.REFRESH_ARTIST &&
command.body.artistId === artist.id
);
});
const isSearchingArtist = executingCommands.some((command) => {
return (
command.name === commandNames.ARTIST_SEARCH &&
command.body.artistId === artist.id
);
});
const latestAlbum = _.maxBy(artist.albums, (album) => album.releaseDate);
return {
...artist,
qualityProfile,
metadataProfile,
latestAlbum,
showSearchAction,
isRefreshingArtist,
isSearchingArtist
};
}
);
}
const mapDispatchToProps = {
dispatchExecuteCommand: executeCommand,
toggleArtistMonitored
};
class ArtistIndexItemConnector extends Component {
//
// Listeners
onRefreshArtistPress = () => {
this.props.dispatchExecuteCommand({
name: commandNames.REFRESH_ARTIST,
artistId: this.props.id
});
};
onSearchPress = () => {
this.props.dispatchExecuteCommand({
name: commandNames.ARTIST_SEARCH,
artistId: this.props.id
});
};
onMonitoredPress = () => {
this.props.toggleArtistMonitored({
artistId: this.props.id,
monitored: !this.props.monitored
});
};
//
// Render
render() {
const {
id,
component: ItemComponent,
...otherProps
} = this.props;
if (!id) {
return null;
}
return (
<ItemComponent
{...otherProps}
id={id}
onRefreshArtistPress={this.onRefreshArtistPress}
onSearchPress={this.onSearchPress}
onMonitoredPress={this.onMonitoredPress}
/>
);
}
}
ArtistIndexItemConnector.propTypes = {
id: PropTypes.number,
monitored: PropTypes.bool.isRequired,
component: PropTypes.elementType.isRequired,
dispatchExecuteCommand: PropTypes.func.isRequired,
toggleArtistMonitored: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ArtistIndexItemConnector);

@ -1,9 +1,5 @@
$hoverScale: 1.05;
.container {
padding: 10px;
}
.content {
transition: all 200ms ease-in;
@ -26,12 +22,29 @@ $hoverScale: 1.05;
.link {
composes: link from '~Components/Link/Link.css';
position: relative;
display: block;
height: 50px;
background-color: var(--defaultColor);
}
.nextAiring {
background-color: #fafbfc;
.overlayTitle {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
width: 100%;
height: 100%;
color: var(--offWhite);
text-align: center;
font-size: 20px;
}
.nextAlbum {
background-color: var(--artistBackgroundColor);
text-align: center;
font-size: $smallFontSize;
}
@ -39,8 +52,7 @@ $hoverScale: 1.05;
.title {
@add-mixin truncate;
background-color: var(--defaultColor);
color: var(--white);
background-color: var(--artistBackgroundColor);
text-align: center;
font-size: $smallFontSize;
}
@ -49,6 +61,7 @@ $hoverScale: 1.05;
position: absolute;
top: 0;
right: 0;
z-index: 1;
width: 0;
height: 0;
border-width: 0 25px 25px 0;

@ -8,7 +8,8 @@ interface CssExports {
'controls': string;
'ended': string;
'link': string;
'nextAiring': string;
'nextAlbum': string;
'overlayTitle': string;
'title': string;
}
export const cssExports: CssExports;

@ -1,271 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ArtistBanner from 'Artist/ArtistBanner';
import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import { icons } from 'Helpers/Props';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import translate from 'Utilities/String/translate';
import ArtistIndexBannerInfo from './ArtistIndexBannerInfo';
import styles from './ArtistIndexBanner.css';
class ArtistIndexBanner extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: false
};
}
//
// Listeners
onEditArtistPress = () => {
this.setState({ isEditArtistModalOpen: true });
};
onEditArtistModalClose = () => {
this.setState({ isEditArtistModalOpen: false });
};
onDeleteArtistPress = () => {
this.setState({
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: true
});
};
onDeleteArtistModalClose = () => {
this.setState({ isDeleteArtistModalOpen: false });
};
//
// Render
render() {
const {
id,
artistName,
monitored,
status,
foreignArtistId,
nextAiring,
statistics,
images,
bannerWidth,
bannerHeight,
detailedProgressBar,
showTitle,
showMonitored,
showQualityProfile,
showSearchAction,
qualityProfile,
showRelativeDates,
shortDateFormat,
timeFormat,
isRefreshingArtist,
isSearchingArtist,
onRefreshArtistPress,
onSearchPress,
...otherProps
} = this.props;
const {
albumCount,
sizeOnDisk,
trackCount,
trackFileCount,
totalTrackCount
} = statistics;
const {
isEditArtistModalOpen,
isDeleteArtistModalOpen
} = this.state;
const link = `/artist/${foreignArtistId}`;
const elementStyle = {
width: `${bannerWidth}px`,
height: `${bannerHeight}px`
};
return (
<div className={styles.container}>
<div className={styles.content}>
<div className={styles.bannerContainer}>
<Label className={styles.controls}>
<SpinnerIconButton
className={styles.action}
name={icons.REFRESH}
title={translate('RefreshArtist')}
isSpinning={isRefreshingArtist}
onPress={onRefreshArtistPress}
/>
{
showSearchAction &&
<SpinnerIconButton
className={styles.action}
name={icons.SEARCH}
title={translate('SearchForMonitoredAlbums')}
isSpinning={isSearchingArtist}
onPress={onSearchPress}
/>
}
<IconButton
className={styles.action}
name={icons.EDIT}
title={translate('EditArtist')}
onPress={this.onEditArtistPress}
/>
</Label>
{
status === 'ended' &&
<div
className={styles.ended}
title={translate('Inactive')}
/>
}
<Link
className={styles.link}
style={elementStyle}
to={link}
>
<ArtistBanner
className={styles.banner}
style={elementStyle}
images={images}
size={70}
lazy={false}
overflow={true}
/>
</Link>
</div>
<ArtistIndexProgressBar
monitored={monitored}
status={status}
trackCount={trackCount}
trackFileCount={trackFileCount}
totalTrackCount={totalTrackCount}
posterWidth={bannerWidth}
detailedProgressBar={detailedProgressBar}
/>
{
showTitle &&
<div className={styles.title}>
{artistName}
</div>
}
{
showMonitored &&
<div className={styles.title}>
{monitored ? 'Monitored' : 'Unmonitored'}
</div>
}
{
showQualityProfile &&
<div className={styles.title}>
{qualityProfile.name}
</div>
}
{
nextAiring &&
<div className={styles.nextAiring}>
{
getRelativeDate(
nextAiring,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true
}
)
}
</div>
}
<ArtistIndexBannerInfo
albumCount={albumCount}
sizeOnDisk={sizeOnDisk}
qualityProfile={qualityProfile}
showQualityProfile={showQualityProfile}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
{...otherProps}
/>
<EditArtistModalConnector
isOpen={isEditArtistModalOpen}
artistId={id}
onModalClose={this.onEditArtistModalClose}
onDeleteArtistPress={this.onDeleteArtistPress}
/>
<DeleteArtistModal
isOpen={isDeleteArtistModalOpen}
artistId={id}
onModalClose={this.onDeleteArtistModalClose}
/>
</div>
</div>
);
}
}
ArtistIndexBanner.propTypes = {
id: PropTypes.number.isRequired,
artistName: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
foreignArtistId: PropTypes.string.isRequired,
nextAiring: PropTypes.string,
statistics: PropTypes.object.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
bannerWidth: PropTypes.number.isRequired,
bannerHeight: PropTypes.number.isRequired,
detailedProgressBar: PropTypes.bool.isRequired,
showTitle: PropTypes.bool.isRequired,
showMonitored: PropTypes.bool.isRequired,
showQualityProfile: PropTypes.bool.isRequired,
qualityProfile: PropTypes.object.isRequired,
showSearchAction: PropTypes.bool.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
isRefreshingArtist: PropTypes.bool.isRequired,
isSearchingArtist: PropTypes.bool.isRequired,
onRefreshArtistPress: PropTypes.func.isRequired,
onSearchPress: PropTypes.func.isRequired
};
ArtistIndexBanner.defaultProps = {
statistics: {
albumCount: 0,
trackCount: 0,
trackFileCount: 0,
totalTrackCount: 0
}
};
export default ArtistIndexBanner;

@ -0,0 +1,257 @@
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Statistics } from 'Artist/Artist';
import ArtistBanner from 'Artist/ArtistBanner';
import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
import ArtistIndexBannerInfo from 'Artist/Index/Banners/ArtistIndexBannerInfo';
import createArtistIndexItemSelector from 'Artist/Index/createArtistIndexItemSelector';
import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar';
import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import { icons } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import translate from 'Utilities/String/translate';
import selectBannerOptions from './selectBannerOptions';
import styles from './ArtistIndexBanner.css';
interface ArtistIndexBannerProps {
artistId: number;
sortKey: string;
bannerWidth: number;
bannerHeight: number;
}
function ArtistIndexBanner(props: ArtistIndexBannerProps) {
const { artistId, sortKey, bannerWidth, bannerHeight } = props;
const {
artist,
qualityProfile,
metadataProfile,
isRefreshingArtist,
isSearchingArtist,
} = useSelector(createArtistIndexItemSelector(props.artistId));
const {
detailedProgressBar,
showTitle,
showMonitored,
showQualityProfile,
showNextAlbum,
showSearchAction,
} = useSelector(selectBannerOptions);
const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } =
useSelector(createUISettingsSelector());
const {
artistName,
artistType,
monitored,
status,
path,
foreignArtistId,
nextAlbum,
added,
statistics = {} as Statistics,
images,
tags,
} = artist;
const {
albumCount = 0,
trackCount = 0,
trackFileCount = 0,
totalTrackCount = 0,
sizeOnDisk = 0,
} = statistics;
const dispatch = useDispatch();
const [hasBannerError, setHasBannerError] = useState(false);
const [isEditArtistModalOpen, setIsEditArtistModalOpen] = useState(false);
const [isDeleteArtistModalOpen, setIsDeleteArtistModalOpen] = useState(false);
const onRefreshPress = useCallback(() => {
dispatch(
executeCommand({
name: REFRESH_ARTIST,
artistId,
})
);
}, [artistId, dispatch]);
const onSearchPress = useCallback(() => {
dispatch(
executeCommand({
name: ARTIST_SEARCH,
artistId,
})
);
}, [artistId, dispatch]);
const onBannerLoadError = useCallback(() => {
setHasBannerError(true);
}, [setHasBannerError]);
const onBannerLoad = useCallback(() => {
setHasBannerError(false);
}, [setHasBannerError]);
const onEditArtistPress = useCallback(() => {
setIsEditArtistModalOpen(true);
}, [setIsEditArtistModalOpen]);
const onEditArtistModalClose = useCallback(() => {
setIsEditArtistModalOpen(false);
}, [setIsEditArtistModalOpen]);
const onDeleteArtistPress = useCallback(() => {
setIsEditArtistModalOpen(false);
setIsDeleteArtistModalOpen(true);
}, [setIsDeleteArtistModalOpen]);
const onDeleteArtistModalClose = useCallback(() => {
setIsDeleteArtistModalOpen(false);
}, [setIsDeleteArtistModalOpen]);
const link = `/artist/${foreignArtistId}`;
const elementStyle = {
width: `${bannerWidth}px`,
height: `${bannerHeight}px`,
};
return (
<div className={styles.content}>
<div className={styles.bannerContainer}>
<Label className={styles.controls}>
<SpinnerIconButton
className={styles.action}
name={icons.REFRESH}
itle={translate('RefreshArtist')}
isSpinning={isRefreshingArtist}
onPress={onRefreshPress}
/>
{showSearchAction ? (
<SpinnerIconButton
className={styles.action}
name={icons.SEARCH}
title={translate('SearchForMonitoredAlbums')}
isSpinning={isSearchingArtist}
onPress={onSearchPress}
/>
) : null}
<IconButton
className={styles.action}
name={icons.EDIT}
title={translate('EditArtist')}
onPress={onEditArtistPress}
/>
</Label>
{status === 'ended' ? (
<div className={styles.ended} title={translate('Inactive')} />
) : null}
<Link className={styles.link} style={elementStyle} to={link}>
<ArtistBanner
style={elementStyle}
images={images}
size={70}
lazy={false}
overflow={true}
onError={onBannerLoadError}
onLoad={onBannerLoad}
/>
{hasBannerError ? (
<div className={styles.overlayTitle}>{artistName}</div>
) : null}
</Link>
</div>
<ArtistIndexProgressBar
monitored={monitored}
status={status}
trackCount={trackCount}
trackFileCount={trackFileCount}
totalTrackCount={totalTrackCount}
posterWidth={bannerWidth}
detailedProgressBar={detailedProgressBar}
/>
{showTitle ? (
<div className={styles.title} title={artistName}>
{artistName}
</div>
) : null}
{showMonitored ? (
<div className={styles.title}>
{monitored ? translate('Monitored') : translate('Unmonitored')}
</div>
) : null}
{showQualityProfile ? (
<div className={styles.title} title={translate('QualityProfile')}>
{qualityProfile.name}
</div>
) : null}
{showNextAlbum && !!nextAlbum?.releaseDate ? (
<div className={styles.nextAlbum} title={translate('NextAlbum')}>
{getRelativeDate(
nextAlbum.releaseDate,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true,
}
)}
</div>
) : null}
<ArtistIndexBannerInfo
artistType={artistType}
added={added}
albumCount={albumCount}
sizeOnDisk={sizeOnDisk}
path={path}
tags={tags}
qualityProfile={qualityProfile}
metadataProfile={metadataProfile}
showQualityProfile={showQualityProfile}
showNextAlbum={showNextAlbum}
showRelativeDates={showRelativeDates}
sortKey={sortKey}
shortDateFormat={shortDateFormat}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
/>
<EditArtistModalConnector
isOpen={isEditArtistModalOpen}
artistId={artistId}
onModalClose={onEditArtistModalClose}
onDeleteArtistPress={onDeleteArtistPress}
/>
<DeleteArtistModal
isOpen={isDeleteArtistModalOpen}
artistId={artistId}
onModalClose={onDeleteArtistModalClose}
/>
</div>
);
}
export default ArtistIndexBanner;

@ -1,115 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import formatBytes from 'Utilities/Number/formatBytes';
import styles from './ArtistIndexBannerInfo.css';
function ArtistIndexBannerInfo(props) {
const {
qualityProfile,
showQualityProfile,
previousAiring,
added,
albumCount,
path,
sizeOnDisk,
sortKey,
showRelativeDates,
shortDateFormat,
timeFormat
} = props;
if (sortKey === 'qualityProfileId' && !showQualityProfile) {
return (
<div className={styles.info}>
{qualityProfile.name}
</div>
);
}
if (sortKey === 'previousAiring' && previousAiring) {
return (
<div className={styles.info}>
{
getRelativeDate(
previousAiring,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true
}
)
}
</div>
);
}
if (sortKey === 'added' && added) {
const addedDate = getRelativeDate(
added,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: false
}
);
return (
<div className={styles.info}>
{`Added ${addedDate}`}
</div>
);
}
if (sortKey === 'albumCount') {
let albums = '1 album';
if (albumCount === 0) {
albums = 'No albums';
} else if (albumCount > 1) {
albums = `${albumCount} albums`;
}
return (
<div className={styles.info}>
{albums}
</div>
);
}
if (sortKey === 'path') {
return (
<div className={styles.info}>
{path}
</div>
);
}
if (sortKey === 'sizeOnDisk') {
return (
<div className={styles.info}>
{formatBytes(sizeOnDisk)}
</div>
);
}
return null;
}
ArtistIndexBannerInfo.propTypes = {
qualityProfile: PropTypes.object.isRequired,
showQualityProfile: PropTypes.bool.isRequired,
previousAiring: PropTypes.string,
added: PropTypes.string,
albumCount: PropTypes.number.isRequired,
path: PropTypes.string.isRequired,
sizeOnDisk: PropTypes.number,
sortKey: PropTypes.string.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired
};
export default ArtistIndexBannerInfo;

@ -0,0 +1,187 @@
import React from 'react';
import Album from 'Album/Album';
import TagListConnector from 'Components/TagListConnector';
import MetadataProfile from 'typings/MetadataProfile';
import QualityProfile from 'typings/QualityProfile';
import formatDateTime from 'Utilities/Date/formatDateTime';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import styles from './ArtistIndexBannerInfo.css';
interface ArtistIndexBannerInfoProps {
artistType?: string;
showQualityProfile: boolean;
qualityProfile?: QualityProfile;
metadataProfile?: MetadataProfile;
showNextAlbum: boolean;
nextAlbum?: Album;
lastAlbum?: Album;
added?: string;
albumCount: number;
path: string;
sizeOnDisk?: number;
tags?: number[];
sortKey: string;
showRelativeDates: boolean;
shortDateFormat: string;
longDateFormat: string;
timeFormat: string;
}
function ArtistIndexBannerInfo(props: ArtistIndexBannerInfoProps) {
const {
artistType,
qualityProfile,
metadataProfile,
showQualityProfile,
showNextAlbum,
nextAlbum,
lastAlbum,
added,
albumCount,
path,
sizeOnDisk,
tags,
sortKey,
showRelativeDates,
shortDateFormat,
longDateFormat,
timeFormat,
} = props;
if (sortKey === 'artistType' && artistType) {
return (
<div className={styles.info} title={translate('ArtistType')}>
{artistType}
</div>
);
}
if (
sortKey === 'qualityProfileId' &&
!showQualityProfile &&
!!qualityProfile?.name
) {
return (
<div className={styles.info} title={translate('QualityProfile')}>
{qualityProfile.name}
</div>
);
}
if (sortKey === 'metadataProfileId' && !!metadataProfile?.name) {
return (
<div className={styles.info} title={translate('MetadataProfile')}>
{metadataProfile.name}
</div>
);
}
if (sortKey === 'nextAlbum' && !showNextAlbum && !!nextAlbum?.releaseDate) {
return (
<div
className={styles.info}
title={`${translate('NextAlbum')}: ${formatDateTime(
nextAlbum.releaseDate,
longDateFormat,
timeFormat
)}`}
>
{getRelativeDate(
nextAlbum.releaseDate,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true,
}
)}
</div>
);
}
if (sortKey === 'lastAlbum' && !!lastAlbum?.releaseDate) {
return (
<div
className={styles.info}
title={`${translate('LastAlbum')}: ${formatDateTime(
lastAlbum.releaseDate,
longDateFormat,
timeFormat
)}`}
>
{getRelativeDate(
lastAlbum.releaseDate,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true,
}
)}
</div>
);
}
if (sortKey === 'added' && added) {
const addedDate = getRelativeDate(
added,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: false,
}
);
return (
<div
className={styles.info}
title={formatDateTime(added, longDateFormat, timeFormat)}
>
{translate('Added')}: {addedDate}
</div>
);
}
if (sortKey === 'albumCount') {
let albums = translate('OneAlbum');
if (albumCount === 0) {
albums = translate('NoAlbums');
} else if (albumCount > 1) {
albums = translate('CountAlbums', { albumCount });
}
return <div className={styles.info}>{albums}</div>;
}
if (sortKey === 'path') {
return (
<div className={styles.info} title={translate('Path')}>
{path}
</div>
);
}
if (sortKey === 'sizeOnDisk') {
return (
<div className={styles.info} title={translate('SizeOnDisk')}>
{formatBytes(sizeOnDisk)}
</div>
);
}
if (sortKey === 'tags' && tags) {
return (
<div className={styles.info} title={translate('Tags')}>
<TagListConnector tags={tags} />
</div>
);
}
return null;
}
export default ArtistIndexBannerInfo;

@ -1,327 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Grid, WindowScroller } from 'react-virtualized';
import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
import Measure from 'Components/Measure';
import dimensions from 'Styles/Variables/dimensions';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder';
import ArtistIndexBanner from './ArtistIndexBanner';
import styles from './ArtistIndexBanners.css';
// container dimensions
const columnPadding = parseInt(dimensions.artistIndexColumnPadding);
const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen);
const progressBarHeight = parseInt(dimensions.progressBarSmallHeight);
const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight);
const additionalColumnCount = {
small: 3,
medium: 2,
large: 1
};
function calculateColumnWidth(width, bannerSize, isSmallScreen) {
const maxiumColumnWidth = isSmallScreen ? 344 : 364;
const columns = Math.floor(width / maxiumColumnWidth);
const remainder = width % maxiumColumnWidth;
if (remainder === 0 && bannerSize === 'large') {
return maxiumColumnWidth;
}
return Math.floor(width / (columns + additionalColumnCount[bannerSize]));
}
function calculateRowHeight(bannerHeight, sortKey, isSmallScreen, bannerOptions) {
const {
detailedProgressBar,
showTitle,
showMonitored,
showQualityProfile
} = bannerOptions;
const nextAiringHeight = 19;
const heights = [
bannerHeight,
detailedProgressBar ? detailedProgressBarHeight : progressBarHeight,
nextAiringHeight,
isSmallScreen ? columnPaddingSmallScreen : columnPadding
];
if (showTitle) {
heights.push(19);
}
if (showMonitored) {
heights.push(19);
}
if (showQualityProfile) {
heights.push(19);
}
switch (sortKey) {
case 'seasons':
case 'previousAiring':
case 'added':
case 'path':
case 'sizeOnDisk':
heights.push(19);
break;
case 'qualityProfileId':
if (!showQualityProfile) {
heights.push(19);
}
break;
default:
// No need to add a height of 0
}
return heights.reduce((acc, height) => acc + height, 0);
}
function calculateHeight(bannerWidth) {
return Math.ceil((88/476) * bannerWidth);
}
class ArtistIndexBanners extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
width: 0,
columnWidth: 364,
columnCount: 1,
bannerWidth: 476,
bannerHeight: 88,
rowHeight: calculateRowHeight(88, null, props.isSmallScreen, {}),
scrollRestored: false
};
this._isInitialized = false;
this._grid = null;
}
componentDidUpdate(prevProps, prevState) {
const {
items,
sortKey,
bannerOptions,
jumpToCharacter,
scrollTop
} = this.props;
const {
width,
columnWidth,
columnCount,
rowHeight,
scrollRestored
} = this.state;
if (prevProps.sortKey !== sortKey ||
prevProps.bannerOptions !== bannerOptions) {
this.calculateGrid();
}
if (this._grid &&
(prevState.width !== width ||
prevState.columnWidth !== columnWidth ||
prevState.columnCount !== columnCount ||
prevState.rowHeight !== rowHeight ||
hasDifferentItemsOrOrder(prevProps.items, items))) {
// recomputeGridSize also forces Grid to discard its cache of rendered cells
this._grid.recomputeGridSize();
}
if (this._grid && scrollTop !== 0 && !scrollRestored) {
this.setState({ scrollRestored: true });
this._grid.scrollToPosition({ scrollTop });
}
if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) {
const index = getIndexOfFirstCharacter(items, jumpToCharacter);
if (this._grid && index != null) {
const row = Math.floor(index / columnCount);
this._grid.scrollToCell({
rowIndex: row,
columnIndex: 0
});
}
}
}
//
// Control
setGridRef = (ref) => {
this._grid = ref;
};
calculateGrid = (width = this.state.width, isSmallScreen) => {
const {
sortKey,
bannerOptions
} = this.props;
const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding;
const columnWidth = calculateColumnWidth(width, bannerOptions.size, isSmallScreen);
const columnCount = Math.max(Math.floor(width / columnWidth), 1);
const bannerWidth = columnWidth - padding;
const bannerHeight = calculateHeight(bannerWidth);
const rowHeight = calculateRowHeight(bannerHeight, sortKey, isSmallScreen, bannerOptions);
this.setState({
width,
columnWidth,
columnCount,
bannerWidth,
bannerHeight,
rowHeight
});
};
cellRenderer = ({ key, rowIndex, columnIndex, style }) => {
const {
items,
sortKey,
bannerOptions,
showRelativeDates,
shortDateFormat,
timeFormat
} = this.props;
const {
bannerWidth,
bannerHeight,
columnCount
} = this.state;
const {
detailedProgressBar,
showTitle,
showMonitored,
showQualityProfile
} = bannerOptions;
const artist = items[rowIndex * columnCount + columnIndex];
if (!artist) {
return null;
}
return (
<div
style={style}
key={key}
>
<ArtistIndexItemConnector
key={artist.id}
component={ArtistIndexBanner}
sortKey={sortKey}
bannerWidth={bannerWidth}
bannerHeight={bannerHeight}
detailedProgressBar={detailedProgressBar}
showTitle={showTitle}
showMonitored={showMonitored}
showQualityProfile={showQualityProfile}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
artistId={artist.id}
qualityProfileId={artist.qualityProfileId}
metadataProfileId={artist.metadataProfileId}
/>
</div>
);
};
//
// Listeners
onMeasure = ({ width }) => {
this.calculateGrid(width, this.props.isSmallScreen);
};
//
// Render
render() {
const {
items,
isSmallScreen,
scroller
} = this.props;
const {
width,
columnWidth,
columnCount,
rowHeight
} = this.state;
const rowCount = Math.ceil(items.length / columnCount);
return (
<Measure
whitelist={['width']}
onMeasure={this.onMeasure}
>
<WindowScroller
scrollElement={isSmallScreen ? undefined : scroller}
>
{({ height, registerChild, onChildScroll, scrollTop }) => {
if (!height) {
return <div />;
}
return (
<Grid
ref={this.setGridRef}
className={styles.grid}
autoHeight={true}
height={height}
columnCount={columnCount}
columnWidth={columnWidth}
rowCount={rowCount}
rowHeight={rowHeight}
width={width}
onScroll={onChildScroll}
scrollTop={scrollTop}
overscanRowCount={2}
cellRenderer={this.cellRenderer}
scrollToAlignment={'start'}
isScrollingOptOut={true}
/>
);
}
}
</WindowScroller>
</Measure>
);
}
}
ArtistIndexBanners.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
bannerOptions: PropTypes.object.isRequired,
jumpToCharacter: PropTypes.string,
scrollTop: PropTypes.number.isRequired,
scroller: PropTypes.instanceOf(Element).isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
timeFormat: PropTypes.string.isRequired
};
export default ArtistIndexBanners;

@ -0,0 +1,294 @@
import { throttle } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { FixedSizeGrid as Grid, GridChildComponentProps } from 'react-window';
import { createSelector } from 'reselect';
import Artist from 'Artist/Artist';
import ArtistIndexBanner from 'Artist/Index/Banners/ArtistIndexBanner';
import useMeasure from 'Helpers/Hooks/useMeasure';
import SortDirection from 'Helpers/Props/SortDirection';
import dimensions from 'Styles/Variables/dimensions';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
const bodyPadding = parseInt(dimensions.pageContentBodyPadding);
const bodyPaddingSmallScreen = parseInt(
dimensions.pageContentBodyPaddingSmallScreen
);
const columnPadding = parseInt(dimensions.artistIndexColumnPadding);
const columnPaddingSmallScreen = parseInt(
dimensions.artistIndexColumnPaddingSmallScreen
);
const progressBarHeight = parseInt(dimensions.progressBarSmallHeight);
const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight);
const ADDITIONAL_COLUMN_COUNT = {
small: 3,
medium: 2,
large: 1,
};
interface CellItemData {
layout: {
columnCount: number;
padding: number;
bannerWidth: number;
bannerHeight: number;
};
items: Artist[];
sortKey: string;
}
interface ArtistIndexBannersProps {
items: Artist[];
sortKey?: string;
sortDirection?: SortDirection;
jumpToCharacter?: string;
scrollTop?: number;
scrollerRef: React.MutableRefObject<HTMLElement>;
isSmallScreen: boolean;
}
const artistIndexSelector = createSelector(
(state) => state.artistIndex.bannerOptions,
(bannerOptions) => {
return {
bannerOptions,
};
}
);
const Cell: React.FC<GridChildComponentProps<CellItemData>> = ({
columnIndex,
rowIndex,
style,
data,
}) => {
const { layout, items, sortKey } = data;
const { columnCount, padding, bannerWidth, bannerHeight } = layout;
const index = rowIndex * columnCount + columnIndex;
if (index >= items.length) {
return null;
}
const artist = items[index];
return (
<div
style={{
padding,
...style,
}}
>
<ArtistIndexBanner
artistId={artist.id}
sortKey={sortKey}
bannerWidth={bannerWidth}
bannerHeight={bannerHeight}
/>
</div>
);
};
function getWindowScrollTopPosition() {
return document.documentElement.scrollTop || document.body.scrollTop || 0;
}
export default function ArtistIndexBanners(props: ArtistIndexBannersProps) {
const { scrollerRef, items, sortKey, jumpToCharacter, isSmallScreen } = props;
const { bannerOptions } = useSelector(artistIndexSelector);
const ref: React.MutableRefObject<Grid> = useRef();
const [measureRef, bounds] = useMeasure();
const [size, setSize] = useState({ width: 0, height: 0 });
const columnWidth = useMemo(() => {
const { width } = size;
const maximumColumnWidth = isSmallScreen ? 344 : 364;
const columns = Math.floor(width / maximumColumnWidth);
const remainder = width % maximumColumnWidth;
return remainder === 0
? maximumColumnWidth
: Math.floor(
width / (columns + ADDITIONAL_COLUMN_COUNT[bannerOptions.size])
);
}, [isSmallScreen, bannerOptions, size]);
const columnCount = useMemo(
() => Math.max(Math.floor(size.width / columnWidth), 1),
[size, columnWidth]
);
const padding = props.isSmallScreen
? columnPaddingSmallScreen
: columnPadding;
const bannerWidth = columnWidth - padding * 2;
const bannerHeight = Math.ceil((88 / 476) * bannerWidth);
const rowHeight = useMemo(() => {
const {
detailedProgressBar,
showTitle,
showMonitored,
showQualityProfile,
showNextAlbum,
} = bannerOptions;
const nextAiringHeight = 19;
const heights = [
bannerHeight,
detailedProgressBar ? detailedProgressBarHeight : progressBarHeight,
nextAiringHeight,
isSmallScreen ? columnPaddingSmallScreen : columnPadding,
];
if (showTitle) {
heights.push(19);
}
if (showMonitored) {
heights.push(19);
}
if (showQualityProfile) {
heights.push(19);
}
if (showNextAlbum) {
heights.push(19);
}
switch (sortKey) {
case 'artistType':
case 'metadataProfileId':
case 'lastAlbum':
case 'added':
case 'albumCount':
case 'path':
case 'sizeOnDisk':
case 'tags':
heights.push(19);
break;
case 'qualityProfileId':
if (!showQualityProfile) {
heights.push(19);
}
break;
case 'nextAlbum':
if (!showNextAlbum) {
heights.push(19);
}
break;
default:
// No need to add a height of 0
}
return heights.reduce((acc, height) => acc + height, 0);
}, [isSmallScreen, bannerOptions, sortKey, bannerHeight]);
useEffect(() => {
const current = scrollerRef.current;
if (isSmallScreen) {
const padding = bodyPaddingSmallScreen - 5;
setSize({
width: window.innerWidth - padding * 2,
height: window.innerHeight,
});
return;
}
if (current) {
const width = current.clientWidth;
const padding = bodyPadding - 5;
setSize({
width: width - padding * 2,
height: window.innerHeight,
});
}
}, [isSmallScreen, scrollerRef, bounds]);
useEffect(() => {
const currentScrollListener = isSmallScreen ? window : scrollerRef.current;
const currentScrollerRef = scrollerRef.current;
const handleScroll = throttle(() => {
const { offsetTop = 0 } = currentScrollerRef;
const scrollTop =
(isSmallScreen
? getWindowScrollTopPosition()
: currentScrollerRef.scrollTop) - offsetTop;
ref.current.scrollTo({ scrollLeft: 0, scrollTop });
}, 10);
currentScrollListener.addEventListener('scroll', handleScroll);
return () => {
handleScroll.cancel();
if (currentScrollListener) {
currentScrollListener.removeEventListener('scroll', handleScroll);
}
};
}, [isSmallScreen, ref, scrollerRef]);
useEffect(() => {
if (jumpToCharacter) {
const index = getIndexOfFirstCharacter(items, jumpToCharacter);
if (index != null) {
const rowIndex = Math.floor(index / columnCount);
const scrollTop = rowIndex * rowHeight + padding;
ref.current.scrollTo({ scrollLeft: 0, scrollTop });
scrollerRef.current.scrollTo(0, scrollTop);
}
}
}, [
jumpToCharacter,
rowHeight,
columnCount,
padding,
items,
scrollerRef,
ref,
]);
return (
<div ref={measureRef}>
<Grid<CellItemData>
ref={ref}
style={{
width: '100%',
height: '100%',
overflow: 'none',
}}
width={size.width}
height={size.height}
columnCount={columnCount}
columnWidth={columnWidth}
rowCount={Math.ceil(items.length / columnCount)}
rowHeight={rowHeight}
itemData={{
layout: {
columnCount,
padding,
bannerWidth,
bannerHeight,
},
items,
sortKey,
}}
>
{Cell}
</Grid>
</div>
);
}

@ -1,25 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import ArtistIndexBanners from './ArtistIndexBanners';
function createMapStateToProps() {
return createSelector(
(state) => state.artistIndex.bannerOptions,
createUISettingsSelector(),
createDimensionsSelector(),
(bannerOptions, uiSettings, dimensions) => {
return {
bannerOptions,
showRelativeDates: uiSettings.showRelativeDates,
shortDateFormat: uiSettings.shortDateFormat,
longDateFormat: uiSettings.longDateFormat,
timeFormat: uiSettings.timeFormat,
isSmallScreen: dimensions.isSmallScreen
};
}
);
}
export default connect(createMapStateToProps)(ArtistIndexBanners);

@ -1,25 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ArtistIndexBannerOptionsModalContentConnector from './ArtistIndexBannerOptionsModalContentConnector';
function ArtistIndexBannerOptionsModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<ArtistIndexBannerOptionsModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
ArtistIndexBannerOptionsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default ArtistIndexBannerOptionsModal;

@ -0,0 +1,21 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ArtistIndexBannerOptionsModalContent from './ArtistIndexBannerOptionsModalContent';
interface ArtistIndexBannerOptionsModalProps {
isOpen: boolean;
onModalClose(...args: unknown[]): unknown;
}
function ArtistIndexBannerOptionsModal({
isOpen,
onModalClose,
}: ArtistIndexBannerOptionsModalProps) {
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ArtistIndexBannerOptionsModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default ArtistIndexBannerOptionsModal;

@ -1,226 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import 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';
const bannerSizeOptions = [
{ key: 'small', value: 'Small' },
{ key: 'medium', value: 'Medium' },
{ key: 'large', value: 'Large' }
];
class ArtistIndexBannerOptionsModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
detailedProgressBar: props.detailedProgressBar,
size: props.size,
showTitle: props.showTitle,
showMonitored: props.showMonitored,
showQualityProfile: props.showQualityProfile,
showSearchAction: props.showSearchAction
};
}
componentDidUpdate(prevProps) {
const {
detailedProgressBar,
size,
showTitle,
showMonitored,
showQualityProfile,
showSearchAction
} = this.props;
const state = {};
if (detailedProgressBar !== prevProps.detailedProgressBar) {
state.detailedProgressBar = detailedProgressBar;
}
if (size !== prevProps.size) {
state.size = size;
}
if (showTitle !== prevProps.showTitle) {
state.showTitle = showTitle;
}
if (showMonitored !== prevProps.showMonitored) {
state.showMonitored = showMonitored;
}
if (showQualityProfile !== prevProps.showQualityProfile) {
state.showQualityProfile = showQualityProfile;
}
if (showSearchAction !== prevProps.showSearchAction) {
state.showSearchAction = showSearchAction;
}
if (!_.isEmpty(state)) {
this.setState(state);
}
}
//
// Listeners
onChangeBannerOption = ({ name, value }) => {
this.setState({
[name]: value
}, () => {
this.props.onChangeBannerOption({ [name]: value });
});
};
//
// Render
render() {
const {
onModalClose
} = this.props;
const {
detailedProgressBar,
size,
showTitle,
showMonitored,
showQualityProfile,
showSearchAction
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Options
</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>
{translate('Size')}
</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="size"
value={size}
values={bannerSizeOptions}
onChange={this.onChangeBannerOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('DetailedProgressBar')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="detailedProgressBar"
value={detailedProgressBar}
helpText={translate('DetailedProgressBarHelpText')}
onChange={this.onChangeBannerOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowName')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showTitle"
value={showTitle}
helpText={translate('ShowTitleHelpText')}
onChange={this.onChangeBannerOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowMonitored')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showMonitored"
value={showMonitored}
helpText={translate('ShowMonitoredHelpText')}
onChange={this.onChangeBannerOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowQualityProfile')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showQualityProfile"
value={showQualityProfile}
helpText={translate('ShowQualityProfileHelpText')}
onChange={this.onChangeBannerOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowSearch')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText={translate('ShowSearchActionHelpText')}
onChange={this.onChangeBannerOption}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
}
ArtistIndexBannerOptionsModalContent.propTypes = {
size: PropTypes.string.isRequired,
showTitle: PropTypes.bool.isRequired,
showQualityProfile: PropTypes.bool.isRequired,
detailedProgressBar: PropTypes.bool.isRequired,
showSearchAction: PropTypes.bool.isRequired,
onChangeBannerOption: PropTypes.func.isRequired,
showMonitored: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default ArtistIndexBannerOptionsModalContent;

@ -0,0 +1,167 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import selectBannerOptions from 'Artist/Index/Banners/selectBannerOptions';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import 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 { setArtistBannerOption } from 'Store/Actions/artistIndexActions';
import translate from 'Utilities/String/translate';
const bannerSizeOptions = [
{
key: 'small',
get value() {
return translate('Small');
},
},
{
key: 'medium',
get value() {
return translate('Medium');
},
},
{
key: 'large',
get value() {
return translate('Large');
},
},
];
interface ArtistIndexBannerOptionsModalContentProps {
onModalClose(...args: unknown[]): unknown;
}
function ArtistIndexBannerOptionsModalContent(
props: ArtistIndexBannerOptionsModalContentProps
) {
const { onModalClose } = props;
const bannerOptions = useSelector(selectBannerOptions);
const {
detailedProgressBar,
size,
showTitle,
showMonitored,
showQualityProfile,
showNextAlbum,
showSearchAction,
} = bannerOptions;
const dispatch = useDispatch();
const onBannerOptionChange = useCallback(
({ name, value }) => {
dispatch(setArtistBannerOption({ [name]: value }));
},
[dispatch]
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('BannerOptions')}</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>{translate('BannerSize')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="size"
value={size}
values={bannerSizeOptions}
onChange={onBannerOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('DetailedProgressBar')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="detailedProgressBar"
value={detailedProgressBar}
helpText={translate('DetailedProgressBarHelpText')}
onChange={onBannerOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowName')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showTitle"
value={showTitle}
helpText={translate('ShowTitleHelpText')}
onChange={onBannerOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowMonitored')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showMonitored"
value={showMonitored}
helpText={translate('ShowMonitoredHelpText')}
onChange={onBannerOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowQualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showQualityProfile"
value={showQualityProfile}
helpText={translate('ShowQualityProfileHelpText')}
onChange={onBannerOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowNextAlbum')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showNextAlbum"
value={showNextAlbum}
helpText={translate('ShowNextAlbumHelpText')}
onChange={onBannerOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowSearch')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText={translate('ShowSearchActionHelpText')}
onChange={onBannerOptionChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default ArtistIndexBannerOptionsModalContent;

@ -1,23 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setArtistBannerOption } from 'Store/Actions/artistIndexActions';
import ArtistIndexBannerOptionsModalContent from './ArtistIndexBannerOptionsModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.artistIndex,
(artistIndex) => {
return artistIndex.bannerOptions;
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onChangeBannerOption(payload) {
dispatch(setArtistBannerOption(payload));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexBannerOptionsModalContent);

@ -0,0 +1,9 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
const selectBannerOptions = createSelector(
(state: AppState) => state.artistIndex.bannerOptions,
(bannerOptions) => bannerOptions
);
export default selectBannerOptions;

@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
import React from 'react';
import ArtistIndexFilterModalConnector from 'Artist/Index/ArtistIndexFilterModalConnector';
import ArtistIndexFilterModal from 'Artist/Index/ArtistIndexFilterModal';
import FilterMenu from 'Components/Menu/FilterMenu';
import { align } from 'Helpers/Props';
@ -10,7 +10,7 @@ function ArtistIndexFilterMenu(props) {
filters,
customFilters,
isDisabled,
onFilterSelect
onFilterSelect,
} = props;
return (
@ -20,22 +20,23 @@ function ArtistIndexFilterMenu(props) {
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={ArtistIndexFilterModalConnector}
filterModalConnectorComponent={ArtistIndexFilterModal}
onFilterSelect={onFilterSelect}
/>
);
}
ArtistIndexFilterMenu.propTypes = {
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
isDisabled: PropTypes.bool.isRequired,
onFilterSelect: PropTypes.func.isRequired
onFilterSelect: PropTypes.func.isRequired,
};
ArtistIndexFilterMenu.defaultProps = {
showCustomFilters: false
showCustomFilters: false,
};
export default ArtistIndexFilterMenu;

@ -6,18 +6,10 @@ import SortMenuItem from 'Components/Menu/SortMenuItem';
import { align, sortDirections } from 'Helpers/Props';
function ArtistIndexSortMenu(props) {
const {
sortKey,
sortDirection,
isDisabled,
onSortSelect
} = props;
const { sortKey, sortDirection, isDisabled, onSortSelect } = props;
return (
<SortMenu
isDisabled={isDisabled}
alignMenu={align.RIGHT}
>
<SortMenu isDisabled={isDisabled} alignMenu={align.RIGHT}>
<MenuContent>
<SortMenuItem
name="status"
@ -153,7 +145,7 @@ ArtistIndexSortMenu.propTypes = {
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
isDisabled: PropTypes.bool.isRequired,
onSortSelect: PropTypes.func.isRequired
onSortSelect: PropTypes.func.isRequired,
};
export default ArtistIndexSortMenu;

@ -4,42 +4,24 @@ import MenuContent from 'Components/Menu/MenuContent';
import ViewMenu from 'Components/Menu/ViewMenu';
import ViewMenuItem from 'Components/Menu/ViewMenuItem';
import { align } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
function ArtistIndexViewMenu(props) {
const {
view,
isDisabled,
onViewSelect
} = props;
const { view, isDisabled, onViewSelect } = props;
return (
<ViewMenu
isDisabled={isDisabled}
alignMenu={align.RIGHT}
>
<ViewMenu isDisabled={isDisabled} alignMenu={align.RIGHT}>
<MenuContent>
<ViewMenuItem
name="table"
selectedView={view}
onPress={onViewSelect}
>
Table
<ViewMenuItem name="table" selectedView={view} onPress={onViewSelect}>
{translate('Table')}
</ViewMenuItem>
<ViewMenuItem
name="posters"
selectedView={view}
onPress={onViewSelect}
>
Posters
<ViewMenuItem name="posters" selectedView={view} onPress={onViewSelect}>
{translate('Posters')}
</ViewMenuItem>
<ViewMenuItem
name="banners"
selectedView={view}
onPress={onViewSelect}
>
Banners
<ViewMenuItem name="banners" selectedView={view} onPress={onViewSelect}>
{translate('Banners')}
</ViewMenuItem>
<ViewMenuItem
@ -47,7 +29,7 @@ function ArtistIndexViewMenu(props) {
selectedView={view}
onPress={onViewSelect}
>
Overview
{translate('Overview')}
</ViewMenuItem>
</MenuContent>
</ViewMenu>
@ -57,7 +39,7 @@ function ArtistIndexViewMenu(props) {
ArtistIndexViewMenu.propTypes = {
view: PropTypes.string.isRequired,
isDisabled: PropTypes.bool.isRequired,
onViewSelect: PropTypes.func.isRequired
onViewSelect: PropTypes.func.isRequired,
};
export default ArtistIndexViewMenu;

@ -1,13 +1,5 @@
$hoverScale: 1.05;
.container {
&:hover {
.content {
background-color: var(--tableRowHoverBackgroundColor);
}
}
}
.content {
display: flex;
flex-grow: 1;

@ -2,7 +2,6 @@
// Please do not change this file!
interface CssExports {
'actions': string;
'container': string;
'content': string;
'details': string;
'ended': string;

@ -1,283 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TextTruncate from 'react-text-truncate';
import ArtistPoster from 'Artist/ArtistPoster';
import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import { icons } from 'Helpers/Props';
import dimensions from 'Styles/Variables/dimensions';
import fonts from 'Styles/Variables/fonts';
import translate from 'Utilities/String/translate';
import ArtistIndexOverviewInfo from './ArtistIndexOverviewInfo';
import styles from './ArtistIndexOverview.css';
const columnPadding = parseInt(dimensions.artistIndexColumnPadding);
const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen);
const defaultFontSize = parseInt(fonts.defaultFontSize);
const lineHeight = parseFloat(fonts.lineHeight);
// Hardcoded height beased on line-height of 32 + bottom margin of 10.
// Less side-effecty than using react-measure.
const titleRowHeight = 42;
function getContentHeight(rowHeight, isSmallScreen) {
const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding;
return rowHeight - padding;
}
class ArtistIndexOverview extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: false
};
}
//
// Listeners
onEditArtistPress = () => {
this.setState({ isEditArtistModalOpen: true });
};
onEditArtistModalClose = () => {
this.setState({ isEditArtistModalOpen: false });
};
onDeleteArtistPress = () => {
this.setState({
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: true
});
};
onDeleteArtistModalClose = () => {
this.setState({ isDeleteArtistModalOpen: false });
};
//
// Render
render() {
const {
id,
artistName,
overview,
monitored,
status,
foreignArtistId,
nextAiring,
statistics,
images,
posterWidth,
posterHeight,
qualityProfile,
overviewOptions,
showSearchAction,
showRelativeDates,
shortDateFormat,
longDateFormat,
timeFormat,
rowHeight,
isSmallScreen,
isRefreshingArtist,
isSearchingArtist,
onRefreshArtistPress,
onSearchPress,
...otherProps
} = this.props;
const {
albumCount,
sizeOnDisk,
trackCount,
trackFileCount,
totalTrackCount
} = statistics;
const {
isEditArtistModalOpen,
isDeleteArtistModalOpen
} = this.state;
const link = `/artist/${foreignArtistId}`;
const elementStyle = {
width: `${posterWidth}px`,
height: `${posterHeight}px`
};
const contentHeight = getContentHeight(rowHeight, isSmallScreen);
const overviewHeight = contentHeight - titleRowHeight;
return (
<div className={styles.container}>
<div className={styles.content}>
<div className={styles.poster}>
<div className={styles.posterContainer}>
{
status === 'ended' &&
<div
className={styles.ended}
title={translate('Inactive')}
/>
}
<Link
className={styles.link}
style={elementStyle}
to={link}
>
<ArtistPoster
className={styles.poster}
style={elementStyle}
images={images}
size={250}
lazy={false}
overflow={true}
/>
</Link>
</div>
<ArtistIndexProgressBar
monitored={monitored}
status={status}
trackCount={trackCount}
trackFileCount={trackFileCount}
totalTrackCount={totalTrackCount}
posterWidth={posterWidth}
detailedProgressBar={overviewOptions.detailedProgressBar}
/>
</div>
<div className={styles.info} style={{ maxHeight: contentHeight }}>
<div className={styles.titleRow}>
<Link
className={styles.title}
to={link}
>
{artistName}
</Link>
<div className={styles.actions}>
<SpinnerIconButton
name={icons.REFRESH}
title={translate('RefreshArtist')}
isSpinning={isRefreshingArtist}
onPress={onRefreshArtistPress}
/>
{
showSearchAction &&
<SpinnerIconButton
className={styles.action}
name={icons.SEARCH}
title={translate('SearchForMonitoredAlbums')}
isSpinning={isSearchingArtist}
onPress={onSearchPress}
/>
}
<IconButton
name={icons.EDIT}
title={translate('EditArtist')}
onPress={this.onEditArtistPress}
/>
</div>
</div>
<div className={styles.details}>
<Link
className={styles.overview}
to={link}
>
<TextTruncate
line={Math.floor(overviewHeight / (defaultFontSize * lineHeight))}
text={overview}
/>
</Link>
<ArtistIndexOverviewInfo
height={overviewHeight}
monitored={monitored}
nextAiring={nextAiring}
albumCount={albumCount}
sizeOnDisk={sizeOnDisk}
qualityProfile={qualityProfile}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
{...overviewOptions}
{...otherProps}
/>
</div>
</div>
</div>
<EditArtistModalConnector
isOpen={isEditArtistModalOpen}
artistId={id}
onModalClose={this.onEditArtistModalClose}
onDeleteArtistPress={this.onDeleteArtistPress}
/>
<DeleteArtistModal
isOpen={isDeleteArtistModalOpen}
artistId={id}
onModalClose={this.onDeleteArtistModalClose}
/>
</div>
);
}
}
ArtistIndexOverview.propTypes = {
id: PropTypes.number.isRequired,
artistName: PropTypes.string.isRequired,
overview: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
foreignArtistId: PropTypes.string.isRequired,
nextAiring: PropTypes.string,
statistics: PropTypes.object.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
posterWidth: PropTypes.number.isRequired,
posterHeight: PropTypes.number.isRequired,
rowHeight: PropTypes.number.isRequired,
qualityProfile: PropTypes.object.isRequired,
overviewOptions: PropTypes.object.isRequired,
showSearchAction: PropTypes.bool.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
isRefreshingArtist: PropTypes.bool.isRequired,
isSearchingArtist: PropTypes.bool.isRequired,
onRefreshArtistPress: PropTypes.func.isRequired,
onSearchPress: PropTypes.func.isRequired
};
ArtistIndexOverview.defaultProps = {
statistics: {
albumCount: 0,
trackCount: 0,
trackFileCount: 0,
totalTrackCount: 0
}
};
export default ArtistIndexOverview;

@ -0,0 +1,240 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import TextTruncate from 'react-text-truncate';
import { Statistics } from 'Artist/Artist';
import ArtistPoster from 'Artist/ArtistPoster';
import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar';
import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import { icons } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions';
import dimensions from 'Styles/Variables/dimensions';
import fonts from 'Styles/Variables/fonts';
import translate from 'Utilities/String/translate';
import createArtistIndexItemSelector from '../createArtistIndexItemSelector';
import ArtistIndexOverviewInfo from './ArtistIndexOverviewInfo';
import selectOverviewOptions from './selectOverviewOptions';
import styles from './ArtistIndexOverview.css';
const columnPadding = parseInt(dimensions.artistIndexColumnPadding);
const columnPaddingSmallScreen = parseInt(
dimensions.artistIndexColumnPaddingSmallScreen
);
const defaultFontSize = parseInt(fonts.defaultFontSize);
const lineHeight = parseFloat(fonts.lineHeight);
// Hardcoded height based on line-height of 32 + bottom margin of 10.
// Less side-effecty than using react-measure.
const TITLE_HEIGHT = 42;
interface ArtistIndexOverviewProps {
artistId: number;
sortKey: string;
posterWidth: number;
posterHeight: number;
rowHeight: number;
isSmallScreen: boolean;
}
function ArtistIndexOverview(props: ArtistIndexOverviewProps) {
const {
artistId,
sortKey,
posterWidth,
posterHeight,
rowHeight,
isSmallScreen,
} = props;
const { artist, qualityProfile, isRefreshingArtist, isSearchingArtist } =
useSelector(createArtistIndexItemSelector(props.artistId));
const overviewOptions = useSelector(selectOverviewOptions);
const {
artistName,
monitored,
status,
path,
foreignArtistId,
nextAlbum,
lastAlbum,
added,
overview,
statistics = {} as Statistics,
images,
} = artist;
const {
albumCount = 0,
sizeOnDisk = 0,
trackCount = 0,
trackFileCount = 0,
totalTrackCount = 0,
} = statistics;
const dispatch = useDispatch();
const [isEditArtistModalOpen, setIsEditArtistModalOpen] = useState(false);
const [isDeleteArtistModalOpen, setIsDeleteArtistModalOpen] = useState(false);
const onRefreshPress = useCallback(() => {
dispatch(
executeCommand({
name: REFRESH_ARTIST,
artistId,
})
);
}, [artistId, dispatch]);
const onSearchPress = useCallback(() => {
dispatch(
executeCommand({
name: ARTIST_SEARCH,
artistId,
})
);
}, [artistId, dispatch]);
const onEditArtistPress = useCallback(() => {
setIsEditArtistModalOpen(true);
}, [setIsEditArtistModalOpen]);
const onEditArtistModalClose = useCallback(() => {
setIsEditArtistModalOpen(false);
}, [setIsEditArtistModalOpen]);
const onDeleteArtistPress = useCallback(() => {
setIsEditArtistModalOpen(false);
setIsDeleteArtistModalOpen(true);
}, [setIsDeleteArtistModalOpen]);
const onDeleteArtistModalClose = useCallback(() => {
setIsDeleteArtistModalOpen(false);
}, [setIsDeleteArtistModalOpen]);
const link = `/artist/${foreignArtistId}`;
const elementStyle = {
width: `${posterWidth}px`,
height: `${posterHeight}px`,
};
const contentHeight = useMemo(() => {
const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding;
return rowHeight - padding;
}, [rowHeight, isSmallScreen]);
const overviewHeight = contentHeight - TITLE_HEIGHT;
return (
<div>
<div className={styles.content}>
<div className={styles.poster}>
<div className={styles.posterContainer}>
{status === 'ended' && (
<div className={styles.ended} title={translate('Inactive')} />
)}
<Link className={styles.link} style={elementStyle} to={link}>
<ArtistPoster
className={styles.poster}
style={elementStyle}
images={images}
size={250}
lazy={false}
overflow={true}
/>
</Link>
</div>
<ArtistIndexProgressBar
monitored={monitored}
status={status}
trackCount={trackCount}
trackFileCount={trackFileCount}
totalTrackCount={totalTrackCount}
posterWidth={posterWidth}
detailedProgressBar={overviewOptions.detailedProgressBar}
/>
</div>
<div className={styles.info} style={{ maxHeight: contentHeight }}>
<div className={styles.titleRow}>
<Link className={styles.title} to={link}>
{artistName}
</Link>
<div className={styles.actions}>
<SpinnerIconButton
name={icons.REFRESH}
title={translate('RefreshArtist')}
isSpinning={isRefreshingArtist}
onPress={onRefreshPress}
/>
{overviewOptions.showSearchAction ? (
<SpinnerIconButton
name={icons.SEARCH}
title={translate('SearchForMonitoredAlbums')}
isSpinning={isSearchingArtist}
onPress={onSearchPress}
/>
) : null}
<IconButton
name={icons.EDIT}
title={translate('EditArtist')}
onPress={onEditArtistPress}
/>
</div>
</div>
<div className={styles.details}>
<Link className={styles.overview} to={link}>
<TextTruncate
line={Math.floor(
overviewHeight / (defaultFontSize * lineHeight)
)}
text={overview}
/>
</Link>
<ArtistIndexOverviewInfo
height={overviewHeight}
monitored={monitored}
nextAlbum={nextAlbum}
lastAlbum={lastAlbum}
added={added}
albumCount={albumCount}
qualityProfile={qualityProfile}
sizeOnDisk={sizeOnDisk}
path={path}
sortKey={sortKey}
{...overviewOptions}
/>
</div>
</div>
</div>
<EditArtistModalConnector
isOpen={isEditArtistModalOpen}
artistId={artistId}
onModalClose={onEditArtistModalClose}
onDeleteArtistPress={onDeleteArtistPress}
/>
<DeleteArtistModal
isOpen={isDeleteArtistModalOpen}
artistId={artistId}
onModalClose={onDeleteArtistModalClose}
/>
</div>
);
}
export default ArtistIndexOverview;

@ -1,249 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { icons } from 'Helpers/Props';
import dimensions from 'Styles/Variables/dimensions';
import formatDateTime from 'Utilities/Date/formatDateTime';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import formatBytes from 'Utilities/Number/formatBytes';
import ArtistIndexOverviewInfoRow from './ArtistIndexOverviewInfoRow';
import styles from './ArtistIndexOverviewInfo.css';
const infoRowHeight = parseInt(dimensions.artistIndexOverviewInfoRowHeight);
const rows = [
{
name: 'monitored',
showProp: 'showMonitored',
valueProp: 'monitored'
},
{
name: 'qualityProfileId',
showProp: 'showQualityProfile',
valueProp: 'qualityProfileId'
},
{
name: 'lastAlbum',
showProp: 'showLastAlbum',
valueProp: 'lastAlbum'
},
{
name: 'added',
showProp: 'showAdded',
valueProp: 'added'
},
{
name: 'albumCount',
showProp: 'showAlbumCount',
valueProp: 'albumCount'
},
{
name: 'path',
showProp: 'showPath',
valueProp: 'path'
},
{
name: 'sizeOnDisk',
showProp: 'showSizeOnDisk',
valueProp: 'sizeOnDisk'
}
];
function isVisible(row, props) {
const {
name,
showProp,
valueProp
} = row;
if (props[valueProp] == null) {
return false;
}
return props[showProp] || props.sortKey === name;
}
function getInfoRowProps(row, props) {
const { name } = row;
if (name === 'monitored') {
const monitoredText = props.monitored ? 'Monitored' : 'Unmonitored';
return {
title: monitoredText,
iconName: props.monitored ? icons.MONITORED : icons.UNMONITORED,
label: monitoredText
};
}
if (name === 'qualityProfileId') {
return {
title: 'Quality Profile',
iconName: icons.PROFILE,
label: props.qualityProfile.name
};
}
if (name === 'lastAlbum') {
const {
lastAlbum,
showRelativeDates,
shortDateFormat,
timeFormat
} = props;
return {
title: `Last Album: ${lastAlbum.title}`,
iconName: icons.CALENDAR,
label: getRelativeDate(
lastAlbum.releaseDate,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true
}
)
};
}
if (name === 'added') {
const {
added,
showRelativeDates,
shortDateFormat,
longDateFormat,
timeFormat
} = props;
return {
title: `Added: ${formatDateTime(added, longDateFormat, timeFormat)}`,
iconName: icons.ADD,
label: getRelativeDate(
added,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true
}
)
};
}
if (name === 'albumCount') {
const { albumCount } = props;
let albums = '1 album';
if (albumCount === 0) {
albums = 'No albums';
} else if (albumCount > 1) {
albums = `${albumCount} albums`;
}
return {
title: 'Album Count',
iconName: icons.CIRCLE,
label: albums
};
}
if (name === 'path') {
return {
title: 'Path',
iconName: icons.FOLDER,
label: props.path
};
}
if (name === 'sizeOnDisk') {
return {
title: 'Size on Disk',
iconName: icons.DRIVE,
label: formatBytes(props.sizeOnDisk)
};
}
}
function ArtistIndexOverviewInfo(props) {
const {
height,
nextAiring,
showRelativeDates,
shortDateFormat,
longDateFormat,
timeFormat
} = props;
let shownRows = 1;
const maxRows = Math.floor(height / (infoRowHeight + 4));
return (
<div className={styles.infos}>
{
!!nextAiring &&
<ArtistIndexOverviewInfoRow
title={formatDateTime(nextAiring, longDateFormat, timeFormat)}
iconName={icons.SCHEDULED}
label={getRelativeDate(
nextAiring,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true
}
)}
/>
}
{
rows.map((row) => {
if (!isVisible(row, props)) {
return null;
}
if (shownRows >= maxRows) {
return null;
}
shownRows++;
const infoRowProps = getInfoRowProps(row, props);
return (
<ArtistIndexOverviewInfoRow
key={row.name}
{...infoRowProps}
/>
);
})
}
</div>
);
}
ArtistIndexOverviewInfo.propTypes = {
height: PropTypes.number.isRequired,
showMonitored: PropTypes.bool.isRequired,
showQualityProfile: PropTypes.bool.isRequired,
showAdded: PropTypes.bool.isRequired,
showAlbumCount: PropTypes.bool.isRequired,
showPath: PropTypes.bool.isRequired,
showSizeOnDisk: PropTypes.bool.isRequired,
monitored: PropTypes.bool.isRequired,
nextAiring: PropTypes.string,
qualityProfile: PropTypes.object.isRequired,
lastAlbum: PropTypes.object,
added: PropTypes.string,
albumCount: PropTypes.number.isRequired,
path: PropTypes.string.isRequired,
sizeOnDisk: PropTypes.number,
sortKey: PropTypes.string.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired
};
export default ArtistIndexOverviewInfo;

@ -0,0 +1,228 @@
import React, { useMemo } from 'react';
import { useSelector } from 'react-redux';
import Album from 'Album/Album';
import { icons } from 'Helpers/Props';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import dimensions from 'Styles/Variables/dimensions';
import formatDateTime from 'Utilities/Date/formatDateTime';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import formatBytes from 'Utilities/Number/formatBytes';
import ArtistIndexOverviewInfoRow from './ArtistIndexOverviewInfoRow';
import styles from './ArtistIndexOverviewInfo.css';
const infoRowHeight = parseInt(dimensions.artistIndexOverviewInfoRowHeight);
const rows = [
{
name: 'monitored',
showProp: 'showMonitored',
valueProp: 'monitored',
},
{
name: 'qualityProfileId',
showProp: 'showQualityProfile',
valueProp: 'qualityProfileId',
},
{
name: 'lastAlbum',
showProp: 'showLastAlbum',
valueProp: 'lastAlbum',
},
{
name: 'added',
showProp: 'showAdded',
valueProp: 'added',
},
{
name: 'albumCount',
showProp: 'showAlbumCount',
valueProp: 'albumCount',
},
{
name: 'path',
showProp: 'showPath',
valueProp: 'path',
},
{
name: 'sizeOnDisk',
showProp: 'showSizeOnDisk',
valueProp: 'sizeOnDisk',
},
];
function getInfoRowProps(row, props, uiSettings) {
const { name } = row;
if (name === 'monitored') {
const monitoredText = props.monitored ? 'Monitored' : 'Unmonitored';
return {
title: monitoredText,
iconName: props.monitored ? icons.MONITORED : icons.UNMONITORED,
label: monitoredText,
};
}
if (name === 'qualityProfileId') {
return {
title: 'Quality Profile',
iconName: icons.PROFILE,
label: props.qualityProfile.name,
};
}
if (name === 'lastAlbum' && !!props.lastAlbum?.title) {
const lastAlbum = props.lastAlbum;
const { showRelativeDates, shortDateFormat, timeFormat } = uiSettings;
return {
title: `Last Album: ${lastAlbum.title}`,
iconName: icons.CALENDAR,
label: getRelativeDate(
lastAlbum.releaseDate,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true,
}
),
};
}
if (name === 'added') {
const added = props.added;
const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } =
uiSettings;
return {
title: `Added: ${formatDateTime(added, longDateFormat, timeFormat)}`,
iconName: icons.ADD,
label: getRelativeDate(added, shortDateFormat, showRelativeDates, {
timeFormat,
timeForToday: true,
}),
};
}
if (name === 'albumCount') {
const { albumCount } = props;
let albums = '1 album';
if (albumCount === 0) {
albums = 'No albums';
} else if (albumCount > 1) {
albums = `${albumCount} albums`;
}
return {
title: 'Album Count',
iconName: icons.CIRCLE,
label: albums,
};
}
if (name === 'path') {
return {
title: 'Path',
iconName: icons.FOLDER,
label: props.path,
};
}
if (name === 'sizeOnDisk') {
return {
title: 'Size on Disk',
iconName: icons.DRIVE,
label: formatBytes(props.sizeOnDisk),
};
}
}
interface ArtistIndexOverviewInfoProps {
height: number;
showMonitored: boolean;
showQualityProfile: boolean;
showLastAlbum: boolean;
showAdded: boolean;
showAlbumCount: boolean;
showPath: boolean;
showSizeOnDisk: boolean;
monitored: boolean;
nextAlbum?: Album;
qualityProfile: object;
lastAlbum?: Album;
added?: string;
albumCount: number;
path: string;
sizeOnDisk?: number;
sortKey: string;
}
function ArtistIndexOverviewInfo(props: ArtistIndexOverviewInfoProps) {
const { height, nextAlbum } = props;
const uiSettings = useSelector(createUISettingsSelector());
const { shortDateFormat, showRelativeDates, longDateFormat, timeFormat } =
uiSettings;
let shownRows = 1;
const maxRows = Math.floor(height / (infoRowHeight + 4));
const rowInfo = useMemo(() => {
return rows.map((row) => {
const { name, showProp, valueProp } = row;
const isVisible =
props[valueProp] != null && (props[showProp] || props.sortKey === name);
return {
...row,
isVisible,
};
});
}, [props]);
return (
<div className={styles.infos}>
{!!nextAlbum?.releaseDate && (
<ArtistIndexOverviewInfoRow
title={formatDateTime(
nextAlbum.releaseDate,
longDateFormat,
timeFormat
)}
iconName={icons.SCHEDULED}
label={getRelativeDate(
nextAlbum.releaseDate,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true,
}
)}
/>
)}
{rowInfo.map((row) => {
if (!row.isVisible) {
return null;
}
if (shownRows >= maxRows) {
return null;
}
shownRows++;
const infoRowProps = getInfoRowProps(row, props, uiSettings);
return <ArtistIndexOverviewInfoRow key={row.name} {...infoRowProps} />;
})}
</div>
);
}
export default ArtistIndexOverviewInfo;

@ -1,35 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import styles from './ArtistIndexOverviewInfoRow.css';
function ArtistIndexOverviewInfoRow(props) {
const {
title,
iconName,
label
} = props;
return (
<div
className={styles.infoRow}
title={title}
>
<Icon
className={styles.icon}
name={iconName}
size={14}
/>
{label}
</div>
);
}
ArtistIndexOverviewInfoRow.propTypes = {
title: PropTypes.string,
iconName: PropTypes.object.isRequired,
label: PropTypes.string.isRequired
};
export default ArtistIndexOverviewInfoRow;

@ -0,0 +1,23 @@
import React from 'react';
import Icon from 'Components/Icon';
import styles from './ArtistIndexOverviewInfoRow.css';
interface ArtistIndexOverviewInfoRowProps {
title?: string;
iconName: object;
label: string;
}
function ArtistIndexOverviewInfoRow(props: ArtistIndexOverviewInfoRowProps) {
const { title, iconName, label } = props;
return (
<div className={styles.infoRow} title={title}>
<Icon className={styles.icon} name={iconName} size={14} />
{label}
</div>
);
}
export default ArtistIndexOverviewInfoRow;

@ -1,275 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Grid, WindowScroller } from 'react-virtualized';
import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
import Measure from 'Components/Measure';
import dimensions from 'Styles/Variables/dimensions';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder';
import ArtistIndexOverview from './ArtistIndexOverview';
import styles from './ArtistIndexOverviews.css';
// Poster container dimensions
const columnPadding = parseInt(dimensions.artistIndexColumnPadding);
const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen);
const progressBarHeight = parseInt(dimensions.progressBarSmallHeight);
const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight);
function calculatePosterWidth(posterSize, isSmallScreen) {
const maxiumPosterWidth = isSmallScreen ? 192 : 202;
if (posterSize === 'large') {
return maxiumPosterWidth;
}
if (posterSize === 'medium') {
return Math.floor(maxiumPosterWidth * 0.75);
}
return Math.floor(maxiumPosterWidth * 0.5);
}
function calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions) {
const {
detailedProgressBar
} = overviewOptions;
const heights = [
posterHeight,
detailedProgressBar ? detailedProgressBarHeight : progressBarHeight,
isSmallScreen ? columnPaddingSmallScreen : columnPadding
];
return heights.reduce((acc, height) => acc + height, 0);
}
function calculatePosterHeight(posterWidth) {
return posterWidth;
}
class ArtistIndexOverviews extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
width: 0,
columnCount: 1,
posterWidth: 238,
posterHeight: 238,
rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {}),
scrollRestored: false
};
this._grid = null;
}
componentDidUpdate(prevProps, prevState) {
const {
items,
sortKey,
overviewOptions,
jumpToCharacter,
scrollTop,
isSmallScreen
} = this.props;
const {
width,
rowHeight,
scrollRestored
} = this.state;
if (prevProps.sortKey !== sortKey ||
prevProps.overviewOptions !== overviewOptions) {
this.calculateGrid(this.state.width, isSmallScreen);
}
if (
this._grid &&
(prevState.width !== width ||
prevState.rowHeight !== rowHeight ||
hasDifferentItemsOrOrder(prevProps.items, items) ||
prevProps.overviewOptions !== overviewOptions
)
) {
// recomputeGridSize also forces Grid to discard its cache of rendered cells
this._grid.recomputeGridSize();
}
if (this._grid && scrollTop !== 0 && !scrollRestored) {
this.setState({ scrollRestored: true });
this._grid.scrollToPosition({ scrollTop });
}
if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) {
const index = getIndexOfFirstCharacter(items, jumpToCharacter);
if (this._grid && index != null) {
this._grid.scrollToCell({
rowIndex: index,
columnIndex: 0
});
}
}
}
//
// Control
setGridRef = (ref) => {
this._grid = ref;
};
calculateGrid = (width = this.state.width, isSmallScreen) => {
const {
sortKey,
overviewOptions
} = this.props;
const posterWidth = calculatePosterWidth(overviewOptions.size, isSmallScreen);
const posterHeight = calculatePosterHeight(posterWidth);
const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions);
this.setState({
width,
posterWidth,
posterHeight,
rowHeight
});
};
cellRenderer = ({ key, rowIndex, style }) => {
const {
items,
sortKey,
overviewOptions,
showRelativeDates,
shortDateFormat,
longDateFormat,
timeFormat,
isSmallScreen
} = this.props;
const {
posterWidth,
posterHeight,
rowHeight
} = this.state;
const artist = items[rowIndex];
if (!artist) {
return null;
}
return (
<div
key={key}
style={style}
>
<ArtistIndexItemConnector
key={artist.id}
component={ArtistIndexOverview}
sortKey={sortKey}
posterWidth={posterWidth}
posterHeight={posterHeight}
rowHeight={rowHeight}
overviewOptions={overviewOptions}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
isSmallScreen={isSmallScreen}
artistId={artist.id}
qualityProfileId={artist.qualityProfileId}
metadataProfileId={artist.metadataProfileId}
/>
</div>
);
};
//
// Listeners
onMeasure = ({ width }) => {
this.calculateGrid(width, this.props.isSmallScreen);
};
//
// Render
render() {
const {
items,
isSmallScreen,
scroller
} = this.props;
const {
width,
rowHeight
} = this.state;
return (
<Measure
whitelist={['width']}
onMeasure={this.onMeasure}
>
<WindowScroller
scrollElement={isSmallScreen ? undefined : scroller}
>
{({ height, registerChild, onChildScroll, scrollTop }) => {
if (!height) {
return <div />;
}
return (
<div ref={registerChild}>
<Grid
ref={this.setGridRef}
className={styles.grid}
autoHeight={true}
height={height}
columnCount={1}
columnWidth={width}
rowCount={items.length}
rowHeight={rowHeight}
width={width}
onScroll={onChildScroll}
scrollTop={scrollTop}
overscanRowCount={2}
cellRenderer={this.cellRenderer}
onSectionRendered={this.onSectionRendered}
scrollToAlignment={'start'}
isScrollingOptOut={true}
/>
</div>
);
}
}
</WindowScroller>
</Measure>
);
}
}
ArtistIndexOverviews.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
overviewOptions: PropTypes.object.isRequired,
scrollTop: PropTypes.number.isRequired,
jumpToCharacter: PropTypes.string,
scroller: PropTypes.instanceOf(Element).isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
timeFormat: PropTypes.string.isRequired
};
export default ArtistIndexOverviews;

@ -0,0 +1,203 @@
import { throttle } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import Artist from 'Artist/Artist';
import useMeasure from 'Helpers/Hooks/useMeasure';
import dimensions from 'Styles/Variables/dimensions';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import ArtistIndexOverview from './ArtistIndexOverview';
import selectOverviewOptions from './selectOverviewOptions';
// Poster container dimensions
const columnPadding = parseInt(dimensions.artistIndexColumnPadding);
const columnPaddingSmallScreen = parseInt(
dimensions.artistIndexColumnPaddingSmallScreen
);
const progressBarHeight = parseInt(dimensions.progressBarSmallHeight);
const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight);
const bodyPadding = parseInt(dimensions.pageContentBodyPadding);
const bodyPaddingSmallScreen = parseInt(
dimensions.pageContentBodyPaddingSmallScreen
);
interface RowItemData {
items: Artist[];
sortKey: string;
posterWidth: number;
posterHeight: number;
rowHeight: number;
isSmallScreen: boolean;
}
interface ArtistIndexOverviewsProps {
items: Artist[];
sortKey?: string;
sortDirection?: string;
jumpToCharacter?: string;
scrollTop?: number;
scrollerRef: React.MutableRefObject<HTMLElement>;
isSmallScreen: boolean;
}
const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
index,
style,
data,
}) => {
const { items, ...otherData } = data;
if (index >= items.length) {
return null;
}
const artist = items[index];
return (
<div style={style}>
<ArtistIndexOverview artistId={artist.id} {...otherData} />
</div>
);
};
function getWindowScrollTopPosition() {
return document.documentElement.scrollTop || document.body.scrollTop || 0;
}
function ArtistIndexOverviews(props: ArtistIndexOverviewsProps) {
const { items, sortKey, jumpToCharacter, isSmallScreen, scrollerRef } = props;
const { size: posterSize, detailedProgressBar } = useSelector(
selectOverviewOptions
);
const listRef: React.MutableRefObject<List> = useRef();
const [measureRef, bounds] = useMeasure();
const [size, setSize] = useState({ width: 0, height: 0 });
const posterWidth = useMemo(() => {
const maxiumPosterWidth = isSmallScreen ? 192 : 202;
if (posterSize === 'large') {
return maxiumPosterWidth;
}
if (posterSize === 'medium') {
return Math.floor(maxiumPosterWidth * 0.75);
}
return Math.floor(maxiumPosterWidth * 0.5);
}, [posterSize, isSmallScreen]);
const posterHeight = useMemo(() => {
return posterWidth;
}, [posterWidth]);
const rowHeight = useMemo(() => {
const heights = [
posterHeight,
detailedProgressBar ? detailedProgressBarHeight : progressBarHeight,
isSmallScreen ? columnPaddingSmallScreen : columnPadding,
];
return heights.reduce((acc, height) => acc + height, 0);
}, [detailedProgressBar, posterHeight, isSmallScreen]);
useEffect(() => {
const current = scrollerRef.current as HTMLElement;
if (isSmallScreen) {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
return;
}
if (current) {
const width = current.clientWidth;
const padding =
(isSmallScreen ? bodyPaddingSmallScreen : bodyPadding) - 5;
setSize({
width: width - padding * 2,
height: window.innerHeight,
});
}
}, [isSmallScreen, scrollerRef, bounds]);
useEffect(() => {
const currentScrollListener = isSmallScreen ? window : scrollerRef.current;
const currentScrollerRef = scrollerRef.current;
const handleScroll = throttle(() => {
const { offsetTop = 0 } = currentScrollerRef;
const scrollTop =
(isSmallScreen
? getWindowScrollTopPosition()
: currentScrollerRef.scrollTop) - offsetTop;
listRef.current.scrollTo(scrollTop);
}, 10);
currentScrollListener.addEventListener('scroll', handleScroll);
return () => {
handleScroll.cancel();
if (currentScrollListener) {
currentScrollListener.removeEventListener('scroll', handleScroll);
}
};
}, [isSmallScreen, listRef, scrollerRef]);
useEffect(() => {
if (jumpToCharacter) {
const index = getIndexOfFirstCharacter(items, jumpToCharacter);
if (index != null) {
let scrollTop = index * rowHeight;
// If the offset is zero go to the top, otherwise offset
// by the approximate size of the header + padding (37 + 20).
if (scrollTop > 0) {
const offset = 57;
scrollTop += offset;
}
listRef.current.scrollTo(scrollTop);
scrollerRef.current.scrollTo(0, scrollTop);
}
}
}, [jumpToCharacter, rowHeight, items, scrollerRef, listRef]);
return (
<div ref={measureRef}>
<List<RowItemData>
ref={listRef}
style={{
width: '100%',
height: '100%',
overflow: 'none',
}}
width={size.width}
height={size.height}
itemCount={items.length}
itemSize={rowHeight}
itemData={{
items,
sortKey,
posterWidth,
posterHeight,
rowHeight,
isSmallScreen,
}}
>
{Row}
</List>
</div>
);
}
export default ArtistIndexOverviews;

@ -1,25 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import ArtistIndexOverviews from './ArtistIndexOverviews';
function createMapStateToProps() {
return createSelector(
(state) => state.artistIndex.overviewOptions,
createUISettingsSelector(),
createDimensionsSelector(),
(overviewOptions, uiSettings, dimensions) => {
return {
overviewOptions,
showRelativeDates: uiSettings.showRelativeDates,
shortDateFormat: uiSettings.shortDateFormat,
longDateFormat: uiSettings.longDateFormat,
timeFormat: uiSettings.timeFormat,
isSmallScreen: dimensions.isSmallScreen
};
}
);
}
export default connect(createMapStateToProps)(ArtistIndexOverviews);

@ -1,25 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ArtistIndexOverviewOptionsModalContentConnector from './ArtistIndexOverviewOptionsModalContentConnector';
function ArtistIndexOverviewOptionsModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<ArtistIndexOverviewOptionsModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
ArtistIndexOverviewOptionsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default ArtistIndexOverviewOptionsModal;

@ -0,0 +1,25 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ArtistIndexOverviewOptionsModalContent from './ArtistIndexOverviewOptionsModalContent';
interface ArtistIndexOverviewOptionsModalProps {
isOpen: boolean;
onModalClose(...args: unknown[]): void;
}
function ArtistIndexOverviewOptionsModal({
isOpen,
onModalClose,
...otherProps
}: ArtistIndexOverviewOptionsModalProps) {
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ArtistIndexOverviewOptionsModalContent
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default ArtistIndexOverviewOptionsModal;

@ -1,308 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import 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';
const posterSizeOptions = [
{ key: 'small', value: 'Small' },
{ key: 'medium', value: 'Medium' },
{ key: 'large', value: 'Large' }
];
class ArtistIndexOverviewOptionsModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
detailedProgressBar: props.detailedProgressBar,
size: props.size,
showMonitored: props.showMonitored,
showQualityProfile: props.showQualityProfile,
showLastAlbum: props.showLastAlbum,
showAdded: props.showAdded,
showAlbumCount: props.showAlbumCount,
showPath: props.showPath,
showSizeOnDisk: props.showSizeOnDisk,
showSearchAction: props.showSearchAction
};
}
componentDidUpdate(prevProps) {
const {
detailedProgressBar,
size,
showMonitored,
showQualityProfile,
showLastAlbum,
showAdded,
showAlbumCount,
showPath,
showSizeOnDisk,
showSearchAction
} = this.props;
const state = {};
if (detailedProgressBar !== prevProps.detailedProgressBar) {
state.detailedProgressBar = detailedProgressBar;
}
if (size !== prevProps.size) {
state.size = size;
}
if (showMonitored !== prevProps.showMonitored) {
state.showMonitored = showMonitored;
}
if (showQualityProfile !== prevProps.showQualityProfile) {
state.showQualityProfile = showQualityProfile;
}
if (showLastAlbum !== prevProps.showLastAlbum) {
state.showLastAlbum = showLastAlbum;
}
if (showAdded !== prevProps.showAdded) {
state.showAdded = showAdded;
}
if (showAlbumCount !== prevProps.showAlbumCount) {
state.showAlbumCount = showAlbumCount;
}
if (showPath !== prevProps.showPath) {
state.showPath = showPath;
}
if (showSizeOnDisk !== prevProps.showSizeOnDisk) {
state.showSizeOnDisk = showSizeOnDisk;
}
if (showSearchAction !== prevProps.showSearchAction) {
state.showSearchAction = showSearchAction;
}
if (!_.isEmpty(state)) {
this.setState(state);
}
}
//
// Listeners
onChangeOverviewOption = ({ name, value }) => {
this.setState({
[name]: value
}, () => {
this.props.onChangeOverviewOption({ [name]: value });
});
};
//
// Render
render() {
const {
onModalClose
} = this.props;
const {
detailedProgressBar,
size,
showMonitored,
showQualityProfile,
showLastAlbum,
showAdded,
showAlbumCount,
showPath,
showSizeOnDisk,
showSearchAction
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Overview Options
</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>
{translate('PosterSize')}
</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="size"
value={size}
values={posterSizeOptions}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('DetailedProgressBar')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="detailedProgressBar"
value={detailedProgressBar}
helpText={translate('DetailedProgressBarHelpText')}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowMonitored')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showMonitored"
value={showMonitored}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowQualityProfile')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showQualityProfile"
value={showQualityProfile}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowLastAlbum')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showLastAlbum"
value={showLastAlbum}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowDateAdded')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showAdded"
value={showAdded}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowAlbumCount')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showAlbumCount"
value={showAlbumCount}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowPath')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showPath"
value={showPath}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowSizeOnDisk')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSizeOnDisk"
value={showSizeOnDisk}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowSearch')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText={translate('ShowSearchActionHelpText')}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
}
ArtistIndexOverviewOptionsModalContent.propTypes = {
size: PropTypes.string.isRequired,
detailedProgressBar: PropTypes.bool.isRequired,
showMonitored: PropTypes.bool.isRequired,
showQualityProfile: PropTypes.bool.isRequired,
showLastAlbum: PropTypes.bool.isRequired,
showAdded: PropTypes.bool.isRequired,
showAlbumCount: PropTypes.bool.isRequired,
showPath: PropTypes.bool.isRequired,
showSizeOnDisk: PropTypes.bool.isRequired,
showSearchAction: PropTypes.bool.isRequired,
onChangeOverviewOption: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default ArtistIndexOverviewOptionsModalContent;

@ -0,0 +1,197 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import 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 { setArtistOverviewOption } from 'Store/Actions/artistIndexActions';
import translate from 'Utilities/String/translate';
import selectOverviewOptions from '../selectOverviewOptions';
const posterSizeOptions = [
{
key: 'small',
get value() {
return translate('Small');
},
},
{
key: 'medium',
get value() {
return translate('Medium');
},
},
{
key: 'large',
get value() {
return translate('Large');
},
},
];
interface ArtistIndexOverviewOptionsModalContentProps {
onModalClose(...args: unknown[]): void;
}
function ArtistIndexOverviewOptionsModalContent(
props: ArtistIndexOverviewOptionsModalContentProps
) {
const { onModalClose } = props;
const {
detailedProgressBar,
size,
showMonitored,
showQualityProfile,
showLastAlbum,
showAdded,
showAlbumCount,
showPath,
showSizeOnDisk,
showSearchAction,
} = useSelector(selectOverviewOptions);
const dispatch = useDispatch();
const onOverviewOptionChange = useCallback(
({ name, value }) => {
dispatch(setArtistOverviewOption({ [name]: value }));
},
[dispatch]
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('OverviewOptions')}</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>{translate('PosterSize')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="size"
value={size}
values={posterSizeOptions}
onChange={onOverviewOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('DetailedProgressBar')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="detailedProgressBar"
value={detailedProgressBar}
helpText={translate('DetailedProgressBarHelpText')}
onChange={onOverviewOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowMonitored')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showMonitored"
value={showMonitored}
onChange={onOverviewOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowQualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showQualityProfile"
value={showQualityProfile}
onChange={onOverviewOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowLastAlbum')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showLastAlbum"
value={showLastAlbum}
onChange={onOverviewOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowDateAdded')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showAdded"
value={showAdded}
onChange={onOverviewOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowAlbumCount')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showAlbumCount"
value={showAlbumCount}
onChange={onOverviewOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowPath')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showPath"
value={showPath}
onChange={onOverviewOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowSizeOnDisk')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSizeOnDisk"
value={showSizeOnDisk}
onChange={onOverviewOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowSearch')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText={translate('ShowSearchActionHelpText')}
onChange={onOverviewOptionChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default ArtistIndexOverviewOptionsModalContent;

@ -1,23 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setArtistOverviewOption } from 'Store/Actions/artistIndexActions';
import ArtistIndexOverviewOptionsModalContent from './ArtistIndexOverviewOptionsModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.artistIndex,
(artistIndex) => {
return artistIndex.overviewOptions;
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onChangeOverviewOption(payload) {
dispatch(setArtistOverviewOption(payload));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexOverviewOptionsModalContent);

@ -0,0 +1,9 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
const selectOverviewOptions = createSelector(
(state: AppState) => state.artistIndex.overviewOptions,
(overviewOptions) => overviewOptions
);
export default selectOverviewOptions;

@ -1,305 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ArtistPoster from 'Artist/ArtistPoster';
import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import { icons } from 'Helpers/Props';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import translate from 'Utilities/String/translate';
import ArtistIndexPosterInfo from './ArtistIndexPosterInfo';
import styles from './ArtistIndexPoster.css';
class ArtistIndexPoster extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
hasPosterError: false,
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: false
};
}
//
// Listeners
onEditArtistPress = () => {
this.setState({ isEditArtistModalOpen: true });
};
onEditArtistModalClose = () => {
this.setState({ isEditArtistModalOpen: false });
};
onDeleteArtistPress = () => {
this.setState({
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: true
});
};
onDeleteArtistModalClose = () => {
this.setState({ isDeleteArtistModalOpen: false });
};
onPosterLoad = () => {
if (this.state.hasPosterError) {
this.setState({ hasPosterError: false });
}
};
onPosterLoadError = () => {
if (!this.state.hasPosterError) {
this.setState({ hasPosterError: true });
}
};
//
// Render
render() {
const {
id,
artistName,
monitored,
foreignArtistId,
status,
nextAlbum,
lastAlbum,
statistics,
images,
posterWidth,
posterHeight,
detailedProgressBar,
showTitle,
showMonitored,
showQualityProfile,
qualityProfile,
showNextAlbum,
showSearchAction,
showRelativeDates,
shortDateFormat,
longDateFormat,
timeFormat,
isRefreshingArtist,
isSearchingArtist,
onRefreshArtistPress,
onSearchPress,
...otherProps
} = this.props;
const {
albumCount,
sizeOnDisk,
trackCount,
trackFileCount,
totalTrackCount
} = statistics;
const {
hasPosterError,
isEditArtistModalOpen,
isDeleteArtistModalOpen
} = this.state;
const link = `/artist/${foreignArtistId}`;
const elementStyle = {
width: `${posterWidth}px`,
height: `${posterHeight}px`
};
return (
<div>
<div className={styles.content}>
<div className={styles.posterContainer} title={artistName}>
<Label className={styles.controls}>
<SpinnerIconButton
className={styles.action}
name={icons.REFRESH}
title={translate('RefreshArtist')}
isSpinning={isRefreshingArtist}
onPress={onRefreshArtistPress}
/>
{
showSearchAction &&
<SpinnerIconButton
className={styles.action}
name={icons.SEARCH}
title={translate('SearchForMonitoredAlbums')}
isSpinning={isSearchingArtist}
onPress={onSearchPress}
/>
}
<IconButton
className={styles.action}
name={icons.EDIT}
title={translate('EditArtist')}
onPress={this.onEditArtistPress}
/>
</Label>
{
status === 'ended' &&
<div
className={styles.ended}
title={translate('Inactive')}
/>
}
<Link
className={styles.link}
style={elementStyle}
to={link}
>
<ArtistPoster
className={styles.poster}
style={elementStyle}
images={images}
size={250}
lazy={false}
overflow={true}
onError={this.onPosterLoadError}
onLoad={this.onPosterLoad}
/>
{
hasPosterError &&
<div className={styles.overlayTitle}>
{artistName}
</div>
}
</Link>
</div>
<ArtistIndexProgressBar
monitored={monitored}
status={status}
trackCount={trackCount}
trackFileCount={trackFileCount}
totalTrackCount={totalTrackCount}
posterWidth={posterWidth}
detailedProgressBar={detailedProgressBar}
/>
{
showTitle &&
<div className={styles.title} title={artistName}>
{artistName}
</div>
}
{
showMonitored &&
<div className={styles.title}>
{monitored ? 'Monitored' : 'Unmonitored'}
</div>
}
{
showQualityProfile &&
<div className={styles.title} title={translate('QualityProfile')}>
{qualityProfile.name}
</div>
}
{
showNextAlbum && !!nextAlbum?.releaseDate &&
<div className={styles.nextAlbum} title={translate('NextAlbum')}>
{
getRelativeDate(
nextAlbum.releaseDate,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true
}
)
}
</div>
}
<ArtistIndexPosterInfo
nextAlbum={nextAlbum}
lastAlbum={lastAlbum}
albumCount={albumCount}
sizeOnDisk={sizeOnDisk}
qualityProfile={qualityProfile}
showQualityProfile={showQualityProfile}
showNextAlbum={showNextAlbum}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
{...otherProps}
/>
<EditArtistModalConnector
isOpen={isEditArtistModalOpen}
artistId={id}
onModalClose={this.onEditArtistModalClose}
onDeleteArtistPress={this.onDeleteArtistPress}
/>
<DeleteArtistModal
isOpen={isDeleteArtistModalOpen}
artistId={id}
onModalClose={this.onDeleteArtistModalClose}
/>
</div>
</div>
);
}
}
ArtistIndexPoster.propTypes = {
id: PropTypes.number.isRequired,
artistName: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
foreignArtistId: PropTypes.string.isRequired,
nextAlbum: PropTypes.object,
lastAlbum: PropTypes.object,
statistics: PropTypes.object.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
posterWidth: PropTypes.number.isRequired,
posterHeight: PropTypes.number.isRequired,
detailedProgressBar: PropTypes.bool.isRequired,
showTitle: PropTypes.bool.isRequired,
showMonitored: PropTypes.bool.isRequired,
showQualityProfile: PropTypes.bool.isRequired,
qualityProfile: PropTypes.object.isRequired,
showNextAlbum: PropTypes.bool.isRequired,
showSearchAction: PropTypes.bool.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
isRefreshingArtist: PropTypes.bool.isRequired,
isSearchingArtist: PropTypes.bool.isRequired,
onRefreshArtistPress: PropTypes.func.isRequired,
onSearchPress: PropTypes.func.isRequired
};
ArtistIndexPoster.defaultProps = {
statistics: {
albumCount: 0,
trackCount: 0,
trackFileCount: 0,
totalTrackCount: 0
}
};
export default ArtistIndexPoster;

@ -0,0 +1,257 @@
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Statistics } from 'Artist/Artist';
import ArtistPoster from 'Artist/ArtistPoster';
import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
import createArtistIndexItemSelector from 'Artist/Index/createArtistIndexItemSelector';
import ArtistIndexPosterInfo from 'Artist/Index/Posters/ArtistIndexPosterInfo';
import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar';
import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import { icons } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import translate from 'Utilities/String/translate';
import selectPosterOptions from './selectPosterOptions';
import styles from './ArtistIndexPoster.css';
interface ArtistIndexPosterProps {
artistId: number;
sortKey: string;
posterWidth: number;
posterHeight: number;
}
function ArtistIndexPoster(props: ArtistIndexPosterProps) {
const { artistId, sortKey, posterWidth, posterHeight } = props;
const {
artist,
qualityProfile,
metadataProfile,
isRefreshingArtist,
isSearchingArtist,
} = useSelector(createArtistIndexItemSelector(props.artistId));
const {
detailedProgressBar,
showTitle,
showMonitored,
showQualityProfile,
showNextAlbum,
showSearchAction,
} = useSelector(selectPosterOptions);
const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } =
useSelector(createUISettingsSelector());
const {
artistName,
artistType,
monitored,
status,
path,
foreignArtistId,
nextAlbum,
added,
statistics = {} as Statistics,
images,
tags,
} = artist;
const {
albumCount = 0,
trackCount = 0,
trackFileCount = 0,
totalTrackCount = 0,
sizeOnDisk = 0,
} = statistics;
const dispatch = useDispatch();
const [hasPosterError, setHasPosterError] = useState(false);
const [isEditArtistModalOpen, setIsEditArtistModalOpen] = useState(false);
const [isDeleteArtistModalOpen, setIsDeleteArtistModalOpen] = useState(false);
const onRefreshPress = useCallback(() => {
dispatch(
executeCommand({
name: REFRESH_ARTIST,
artistId,
})
);
}, [artistId, dispatch]);
const onSearchPress = useCallback(() => {
dispatch(
executeCommand({
name: ARTIST_SEARCH,
artistId,
})
);
}, [artistId, dispatch]);
const onPosterLoadError = useCallback(() => {
setHasPosterError(true);
}, [setHasPosterError]);
const onPosterLoad = useCallback(() => {
setHasPosterError(false);
}, [setHasPosterError]);
const onEditArtistPress = useCallback(() => {
setIsEditArtistModalOpen(true);
}, [setIsEditArtistModalOpen]);
const onEditArtistModalClose = useCallback(() => {
setIsEditArtistModalOpen(false);
}, [setIsEditArtistModalOpen]);
const onDeleteArtistPress = useCallback(() => {
setIsEditArtistModalOpen(false);
setIsDeleteArtistModalOpen(true);
}, [setIsDeleteArtistModalOpen]);
const onDeleteArtistModalClose = useCallback(() => {
setIsDeleteArtistModalOpen(false);
}, [setIsDeleteArtistModalOpen]);
const link = `/artist/${foreignArtistId}`;
const elementStyle = {
width: `${posterWidth}px`,
height: `${posterHeight}px`,
};
return (
<div className={styles.content}>
<div className={styles.posterContainer}>
<Label className={styles.controls}>
<SpinnerIconButton
className={styles.action}
name={icons.REFRESH}
itle={translate('RefreshArtist')}
isSpinning={isRefreshingArtist}
onPress={onRefreshPress}
/>
{showSearchAction ? (
<SpinnerIconButton
className={styles.action}
name={icons.SEARCH}
title={translate('SearchForMonitoredAlbums')}
isSpinning={isSearchingArtist}
onPress={onSearchPress}
/>
) : null}
<IconButton
className={styles.action}
name={icons.EDIT}
title={translate('EditArtist')}
onPress={onEditArtistPress}
/>
</Label>
{status === 'ended' ? (
<div className={styles.ended} title={translate('Inactive')} />
) : null}
<Link className={styles.link} style={elementStyle} to={link}>
<ArtistPoster
style={elementStyle}
images={images}
size={250}
lazy={false}
overflow={true}
onError={onPosterLoadError}
onLoad={onPosterLoad}
/>
{hasPosterError ? (
<div className={styles.overlayTitle}>{artistName}</div>
) : null}
</Link>
</div>
<ArtistIndexProgressBar
monitored={monitored}
status={status}
trackCount={trackCount}
trackFileCount={trackFileCount}
totalTrackCount={totalTrackCount}
posterWidth={posterWidth}
detailedProgressBar={detailedProgressBar}
/>
{showTitle ? (
<div className={styles.title} title={artistName}>
{artistName}
</div>
) : null}
{showMonitored ? (
<div className={styles.title}>
{monitored ? translate('Monitored') : translate('Unmonitored')}
</div>
) : null}
{showQualityProfile ? (
<div className={styles.title} title={translate('QualityProfile')}>
{qualityProfile.name}
</div>
) : null}
{showNextAlbum && !!nextAlbum?.releaseDate ? (
<div className={styles.nextAlbum} title={translate('NextAlbum')}>
{getRelativeDate(
nextAlbum.releaseDate,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true,
}
)}
</div>
) : null}
<ArtistIndexPosterInfo
artistType={artistType}
added={added}
albumCount={albumCount}
sizeOnDisk={sizeOnDisk}
path={path}
tags={tags}
qualityProfile={qualityProfile}
metadataProfile={metadataProfile}
showQualityProfile={showQualityProfile}
showNextAlbum={showNextAlbum}
showRelativeDates={showRelativeDates}
sortKey={sortKey}
shortDateFormat={shortDateFormat}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
/>
<EditArtistModalConnector
isOpen={isEditArtistModalOpen}
artistId={artistId}
onModalClose={onEditArtistModalClose}
onDeleteArtistPress={onDeleteArtistPress}
/>
<DeleteArtistModal
isOpen={isDeleteArtistModalOpen}
artistId={artistId}
onModalClose={onDeleteArtistModalClose}
/>
</div>
);
}
export default ArtistIndexPoster;

@ -1,16 +1,39 @@
import PropTypes from 'prop-types';
import React from 'react';
import Album from 'Album/Album';
import TagListConnector from 'Components/TagListConnector';
import MetadataProfile from 'typings/MetadataProfile';
import QualityProfile from 'typings/QualityProfile';
import formatDateTime from 'Utilities/Date/formatDateTime';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import styles from './ArtistIndexPosterInfo.css';
function ArtistIndexPosterInfo(props) {
interface ArtistIndexPosterInfoProps {
artistType?: string;
showQualityProfile: boolean;
qualityProfile?: QualityProfile;
metadataProfile?: MetadataProfile;
showNextAlbum: boolean;
nextAlbum?: Album;
lastAlbum?: Album;
added?: string;
albumCount: number;
path: string;
sizeOnDisk?: number;
tags?: number[];
sortKey: string;
showRelativeDates: boolean;
shortDateFormat: string;
longDateFormat: string;
timeFormat: string;
}
function ArtistIndexPosterInfo(props: ArtistIndexPosterInfoProps) {
const {
artistType,
qualityProfile,
metadataProfile,
showQualityProfile,
showNextAlbum,
nextAlbum,
@ -24,7 +47,7 @@ function ArtistIndexPosterInfo(props) {
showRelativeDates,
shortDateFormat,
longDateFormat,
timeFormat
timeFormat,
} = props;
if (sortKey === 'artistType' && artistType) {
@ -35,7 +58,11 @@ function ArtistIndexPosterInfo(props) {
);
}
if (sortKey === 'qualityProfileId' && !showQualityProfile) {
if (
sortKey === 'qualityProfileId' &&
!showQualityProfile &&
!!qualityProfile?.name
) {
return (
<div className={styles.info} title={translate('QualityProfile')}>
{qualityProfile.name}
@ -43,6 +70,14 @@ function ArtistIndexPosterInfo(props) {
);
}
if (sortKey === 'metadataProfileId' && !!metadataProfile?.name) {
return (
<div className={styles.info} title={translate('MetadataProfile')}>
{metadataProfile.name}
</div>
);
}
if (sortKey === 'nextAlbum' && !showNextAlbum && !!nextAlbum?.releaseDate) {
return (
<div
@ -53,17 +88,15 @@ function ArtistIndexPosterInfo(props) {
timeFormat
)}`}
>
{
getRelativeDate(
nextAlbum.releaseDate,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true
}
)
}
{getRelativeDate(
nextAlbum.releaseDate,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true,
}
)}
</div>
);
}
@ -78,17 +111,15 @@ function ArtistIndexPosterInfo(props) {
timeFormat
)}`}
>
{
getRelativeDate(
lastAlbum.releaseDate,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true
}
)
}
{getRelativeDate(
lastAlbum.releaseDate,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true,
}
)}
</div>
);
}
@ -100,7 +131,7 @@ function ArtistIndexPosterInfo(props) {
showRelativeDates,
{
timeFormat,
timeForToday: false
timeForToday: false,
}
);
@ -123,11 +154,7 @@ function ArtistIndexPosterInfo(props) {
albums = translate('CountAlbums', { albumCount });
}
return (
<div className={styles.info}>
{albums}
</div>
);
return <div className={styles.info}>{albums}</div>;
}
if (sortKey === 'path') {
@ -146,12 +173,10 @@ function ArtistIndexPosterInfo(props) {
);
}
if (sortKey === 'tags') {
if (sortKey === 'tags' && tags) {
return (
<div className={styles.info} title={translate('Tags')}>
<TagListConnector
tags={tags}
/>
<TagListConnector tags={tags} />
</div>
);
}
@ -159,23 +184,4 @@ function ArtistIndexPosterInfo(props) {
return null;
}
ArtistIndexPosterInfo.propTypes = {
artistType: PropTypes.string,
qualityProfile: PropTypes.object.isRequired,
showQualityProfile: PropTypes.bool.isRequired,
showNextAlbum: PropTypes.bool.isRequired,
nextAlbum: PropTypes.object,
lastAlbum: PropTypes.object,
added: PropTypes.string,
albumCount: PropTypes.number.isRequired,
path: PropTypes.string.isRequired,
sizeOnDisk: PropTypes.number,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
sortKey: PropTypes.string.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired
};
export default ArtistIndexPosterInfo;

@ -1,351 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Grid, WindowScroller } from 'react-virtualized';
import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
import Measure from 'Components/Measure';
import dimensions from 'Styles/Variables/dimensions';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder';
import ArtistIndexPoster from './ArtistIndexPoster';
import styles from './ArtistIndexPosters.css';
// Poster container dimensions
const columnPadding = parseInt(dimensions.artistIndexColumnPadding);
const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen);
const progressBarHeight = parseInt(dimensions.progressBarSmallHeight);
const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight);
const additionalColumnCount = {
small: 3,
medium: 2,
large: 1
};
function calculateColumnWidth(width, posterSize, isSmallScreen) {
const maxiumColumnWidth = isSmallScreen ? 172 : 182;
const columns = Math.floor(width / maxiumColumnWidth);
const remainder = width % maxiumColumnWidth;
if (remainder === 0 && posterSize === 'large') {
return maxiumColumnWidth;
}
return Math.floor(width / (columns + additionalColumnCount[posterSize]));
}
function calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions) {
const {
detailedProgressBar,
showTitle,
showMonitored,
showQualityProfile,
showNextAlbum
} = posterOptions;
const nextAiringHeight = 19;
const heights = [
posterHeight,
detailedProgressBar ? detailedProgressBarHeight : progressBarHeight,
nextAiringHeight,
isSmallScreen ? columnPaddingSmallScreen : columnPadding
];
if (showTitle) {
heights.push(19);
}
if (showMonitored) {
heights.push(19);
}
if (showQualityProfile) {
heights.push(19);
}
if (showNextAlbum) {
heights.push(19);
}
switch (sortKey) {
case 'artistType':
case 'lastAlbum':
case 'seasons':
case 'added':
case 'albumCount':
case 'path':
case 'sizeOnDisk':
case 'tags':
heights.push(19);
break;
case 'qualityProfileId':
if (!showQualityProfile) {
heights.push(19);
}
break;
case 'nextAlbum':
if (!showNextAlbum) {
heights.push(19);
}
break;
default:
// No need to add a height of 0
}
return heights.reduce((acc, height) => acc + height, 0);
}
function calculatePosterHeight(posterWidth) {
return Math.ceil(posterWidth);
}
class ArtistIndexPosters extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
width: 0,
columnWidth: 182,
columnCount: 1,
posterWidth: 238,
posterHeight: 238,
rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {}),
scrollRestored: false
};
this._isInitialized = false;
this._grid = null;
this._padding = props.isSmallScreen ? columnPaddingSmallScreen : columnPadding;
}
componentDidUpdate(prevProps, prevState) {
const {
items,
sortKey,
posterOptions,
jumpToCharacter,
scrollTop,
isSmallScreen
} = this.props;
const {
width,
columnWidth,
columnCount,
rowHeight,
scrollRestored
} = this.state;
if (prevProps.sortKey !== sortKey ||
prevProps.posterOptions !== posterOptions) {
this.calculateGrid(width, isSmallScreen);
}
if (this._grid &&
(prevState.width !== width ||
prevState.columnWidth !== columnWidth ||
prevState.columnCount !== columnCount ||
prevState.rowHeight !== rowHeight ||
hasDifferentItemsOrOrder(prevProps.items, items))) {
// recomputeGridSize also forces Grid to discard its cache of rendered cells
this._grid.recomputeGridSize();
}
if (this._grid && scrollTop !== 0 && !scrollRestored) {
this.setState({ scrollRestored: true });
this._grid.scrollToPosition({ scrollTop });
}
if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) {
const index = getIndexOfFirstCharacter(items, jumpToCharacter);
if (this._grid && index != null) {
const row = Math.floor(index / columnCount);
this._grid.scrollToCell({
rowIndex: row,
columnIndex: 0
});
}
}
}
//
// Control
setGridRef = (ref) => {
this._grid = ref;
};
calculateGrid = (width = this.state.width, isSmallScreen) => {
const {
sortKey,
posterOptions
} = this.props;
const columnWidth = calculateColumnWidth(width, posterOptions.size, isSmallScreen);
const columnCount = Math.max(Math.floor(width / columnWidth), 1);
const posterWidth = columnWidth - this._padding * 2;
const posterHeight = calculatePosterHeight(posterWidth);
const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions);
this.setState({
width,
columnWidth,
columnCount,
posterWidth,
posterHeight,
rowHeight
});
};
cellRenderer = ({ key, rowIndex, columnIndex, style }) => {
const {
items,
sortKey,
posterOptions,
showRelativeDates,
shortDateFormat,
longDateFormat,
timeFormat
} = this.props;
const {
posterWidth,
posterHeight,
columnCount
} = this.state;
const {
detailedProgressBar,
showTitle,
showMonitored,
showQualityProfile,
showNextAlbum
} = posterOptions;
const artist = items[rowIndex * columnCount + columnIndex];
if (!artist) {
return null;
}
return (
<div
key={key}
style={{
...style,
padding: this._padding
}}
>
<ArtistIndexItemConnector
key={artist.id}
component={ArtistIndexPoster}
sortKey={sortKey}
posterWidth={posterWidth}
posterHeight={posterHeight}
detailedProgressBar={detailedProgressBar}
showTitle={showTitle}
showMonitored={showMonitored}
showQualityProfile={showQualityProfile}
showNextAlbum={showNextAlbum}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
style={style}
artistId={artist.id}
qualityProfileId={artist.qualityProfileId}
metadataProfileId={artist.metadataProfileId}
/>
</div>
);
};
//
// Listeners
onMeasure = ({ width }) => {
this.calculateGrid(width, this.props.isSmallScreen);
};
//
// Render
render() {
const {
scroller,
items,
isSmallScreen
} = this.props;
const {
width,
columnWidth,
columnCount,
rowHeight
} = this.state;
const rowCount = Math.ceil(items.length / columnCount);
return (
<Measure
whitelist={['width']}
onMeasure={this.onMeasure}
>
<WindowScroller
scrollElement={isSmallScreen ? undefined : scroller}
>
{({ height, registerChild, onChildScroll, scrollTop }) => {
if (!height) {
return <div />;
}
return (
<div ref={registerChild}>
<Grid
ref={this.setGridRef}
className={styles.grid}
autoHeight={true}
height={height}
columnCount={columnCount}
columnWidth={columnWidth}
rowCount={rowCount}
rowHeight={rowHeight}
width={width}
onScroll={onChildScroll}
scrollTop={scrollTop}
overscanRowCount={2}
cellRenderer={this.cellRenderer}
scrollToAlignment={'start'}
isScrollingOptOut={true}
/>
</div>
);
}
}
</WindowScroller>
</Measure>
);
}
}
ArtistIndexPosters.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
posterOptions: PropTypes.object.isRequired,
jumpToCharacter: PropTypes.string,
scrollTop: PropTypes.number.isRequired,
scroller: PropTypes.instanceOf(Element).isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
isSmallScreen: PropTypes.bool.isRequired
};
export default ArtistIndexPosters;

@ -0,0 +1,294 @@
import { throttle } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { FixedSizeGrid as Grid, GridChildComponentProps } from 'react-window';
import { createSelector } from 'reselect';
import Artist from 'Artist/Artist';
import ArtistIndexPoster from 'Artist/Index/Posters/ArtistIndexPoster';
import useMeasure from 'Helpers/Hooks/useMeasure';
import SortDirection from 'Helpers/Props/SortDirection';
import dimensions from 'Styles/Variables/dimensions';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
const bodyPadding = parseInt(dimensions.pageContentBodyPadding);
const bodyPaddingSmallScreen = parseInt(
dimensions.pageContentBodyPaddingSmallScreen
);
const columnPadding = parseInt(dimensions.artistIndexColumnPadding);
const columnPaddingSmallScreen = parseInt(
dimensions.artistIndexColumnPaddingSmallScreen
);
const progressBarHeight = parseInt(dimensions.progressBarSmallHeight);
const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight);
const ADDITIONAL_COLUMN_COUNT = {
small: 3,
medium: 2,
large: 1,
};
interface CellItemData {
layout: {
columnCount: number;
padding: number;
posterWidth: number;
posterHeight: number;
};
items: Artist[];
sortKey: string;
}
interface ArtistIndexPostersProps {
items: Artist[];
sortKey?: string;
sortDirection?: SortDirection;
jumpToCharacter?: string;
scrollTop?: number;
scrollerRef: React.MutableRefObject<HTMLElement>;
isSmallScreen: boolean;
}
const artistIndexSelector = createSelector(
(state) => state.artistIndex.posterOptions,
(posterOptions) => {
return {
posterOptions,
};
}
);
const Cell: React.FC<GridChildComponentProps<CellItemData>> = ({
columnIndex,
rowIndex,
style,
data,
}) => {
const { layout, items, sortKey } = data;
const { columnCount, padding, posterWidth, posterHeight } = layout;
const index = rowIndex * columnCount + columnIndex;
if (index >= items.length) {
return null;
}
const artist = items[index];
return (
<div
style={{
padding,
...style,
}}
>
<ArtistIndexPoster
artistId={artist.id}
sortKey={sortKey}
posterWidth={posterWidth}
posterHeight={posterHeight}
/>
</div>
);
};
function getWindowScrollTopPosition() {
return document.documentElement.scrollTop || document.body.scrollTop || 0;
}
export default function ArtistIndexPosters(props: ArtistIndexPostersProps) {
const { scrollerRef, items, sortKey, jumpToCharacter, isSmallScreen } = props;
const { posterOptions } = useSelector(artistIndexSelector);
const ref: React.MutableRefObject<Grid> = useRef();
const [measureRef, bounds] = useMeasure();
const [size, setSize] = useState({ width: 0, height: 0 });
const columnWidth = useMemo(() => {
const { width } = size;
const maximumColumnWidth = isSmallScreen ? 172 : 182;
const columns = Math.floor(width / maximumColumnWidth);
const remainder = width % maximumColumnWidth;
return remainder === 0
? maximumColumnWidth
: Math.floor(
width / (columns + ADDITIONAL_COLUMN_COUNT[posterOptions.size])
);
}, [isSmallScreen, posterOptions, size]);
const columnCount = useMemo(
() => Math.max(Math.floor(size.width / columnWidth), 1),
[size, columnWidth]
);
const padding = props.isSmallScreen
? columnPaddingSmallScreen
: columnPadding;
const posterWidth = columnWidth - padding * 2;
const posterHeight = Math.ceil(posterWidth);
const rowHeight = useMemo(() => {
const {
detailedProgressBar,
showTitle,
showMonitored,
showQualityProfile,
showNextAlbum,
} = posterOptions;
const nextAiringHeight = 19;
const heights = [
posterHeight,
detailedProgressBar ? detailedProgressBarHeight : progressBarHeight,
nextAiringHeight,
isSmallScreen ? columnPaddingSmallScreen : columnPadding,
];
if (showTitle) {
heights.push(19);
}
if (showMonitored) {
heights.push(19);
}
if (showQualityProfile) {
heights.push(19);
}
if (showNextAlbum) {
heights.push(19);
}
switch (sortKey) {
case 'artistType':
case 'metadataProfileId':
case 'lastAlbum':
case 'added':
case 'albumCount':
case 'path':
case 'sizeOnDisk':
case 'tags':
heights.push(19);
break;
case 'qualityProfileId':
if (!showQualityProfile) {
heights.push(19);
}
break;
case 'nextAlbum':
if (!showNextAlbum) {
heights.push(19);
}
break;
default:
// No need to add a height of 0
}
return heights.reduce((acc, height) => acc + height, 0);
}, [isSmallScreen, posterOptions, sortKey, posterHeight]);
useEffect(() => {
const current = scrollerRef.current;
if (isSmallScreen) {
const padding = bodyPaddingSmallScreen - 5;
setSize({
width: window.innerWidth - padding * 2,
height: window.innerHeight,
});
return;
}
if (current) {
const width = current.clientWidth;
const padding = bodyPadding - 5;
setSize({
width: width - padding * 2,
height: window.innerHeight,
});
}
}, [isSmallScreen, scrollerRef, bounds]);
useEffect(() => {
const currentScrollListener = isSmallScreen ? window : scrollerRef.current;
const currentScrollerRef = scrollerRef.current;
const handleScroll = throttle(() => {
const { offsetTop = 0 } = currentScrollerRef;
const scrollTop =
(isSmallScreen
? getWindowScrollTopPosition()
: currentScrollerRef.scrollTop) - offsetTop;
ref.current.scrollTo({ scrollLeft: 0, scrollTop });
}, 10);
currentScrollListener.addEventListener('scroll', handleScroll);
return () => {
handleScroll.cancel();
if (currentScrollListener) {
currentScrollListener.removeEventListener('scroll', handleScroll);
}
};
}, [isSmallScreen, ref, scrollerRef]);
useEffect(() => {
if (jumpToCharacter) {
const index = getIndexOfFirstCharacter(items, jumpToCharacter);
if (index != null) {
const rowIndex = Math.floor(index / columnCount);
const scrollTop = rowIndex * rowHeight + padding;
ref.current.scrollTo({ scrollLeft: 0, scrollTop });
scrollerRef.current.scrollTo(0, scrollTop);
}
}
}, [
jumpToCharacter,
rowHeight,
columnCount,
padding,
items,
scrollerRef,
ref,
]);
return (
<div ref={measureRef}>
<Grid<CellItemData>
ref={ref}
style={{
width: '100%',
height: '100%',
overflow: 'none',
}}
width={size.width}
height={size.height}
columnCount={columnCount}
columnWidth={columnWidth}
rowCount={Math.ceil(items.length / columnCount)}
rowHeight={rowHeight}
itemData={{
layout: {
columnCount,
padding,
posterWidth,
posterHeight,
},
items,
sortKey,
}}
>
{Cell}
</Grid>
</div>
);
}

@ -1,25 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import ArtistIndexPosters from './ArtistIndexPosters';
function createMapStateToProps() {
return createSelector(
(state) => state.artistIndex.posterOptions,
createUISettingsSelector(),
createDimensionsSelector(),
(posterOptions, uiSettings, dimensions) => {
return {
posterOptions,
showRelativeDates: uiSettings.showRelativeDates,
shortDateFormat: uiSettings.shortDateFormat,
longDateFormat: uiSettings.longDateFormat,
timeFormat: uiSettings.timeFormat,
isSmallScreen: dimensions.isSmallScreen
};
}
);
}
export default connect(createMapStateToProps)(ArtistIndexPosters);

@ -1,25 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ArtistIndexPosterOptionsModalContentConnector from './ArtistIndexPosterOptionsModalContentConnector';
function ArtistIndexPosterOptionsModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<ArtistIndexPosterOptionsModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
ArtistIndexPosterOptionsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default ArtistIndexPosterOptionsModal;

@ -0,0 +1,21 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ArtistIndexPosterOptionsModalContent from './ArtistIndexPosterOptionsModalContent';
interface ArtistIndexPosterOptionsModalProps {
isOpen: boolean;
onModalClose(...args: unknown[]): unknown;
}
function ArtistIndexPosterOptionsModal({
isOpen,
onModalClose,
}: ArtistIndexPosterOptionsModalProps) {
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ArtistIndexPosterOptionsModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default ArtistIndexPosterOptionsModal;

@ -1,248 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import 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';
const posterSizeOptions = [
{ key: 'small', value: 'Small' },
{ key: 'medium', value: 'Medium' },
{ key: 'large', value: 'Large' }
];
class ArtistIndexPosterOptionsModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
detailedProgressBar: props.detailedProgressBar,
size: props.size,
showTitle: props.showTitle,
showMonitored: props.showMonitored,
showQualityProfile: props.showQualityProfile,
showNextAlbum: props.showNextAlbum,
showSearchAction: props.showSearchAction
};
}
componentDidUpdate(prevProps) {
const {
detailedProgressBar,
size,
showTitle,
showMonitored,
showQualityProfile,
showNextAlbum,
showSearchAction
} = this.props;
const state = {};
if (detailedProgressBar !== prevProps.detailedProgressBar) {
state.detailedProgressBar = detailedProgressBar;
}
if (size !== prevProps.size) {
state.size = size;
}
if (showTitle !== prevProps.showTitle) {
state.showTitle = showTitle;
}
if (showMonitored !== prevProps.showMonitored) {
state.showMonitored = showMonitored;
}
if (showQualityProfile !== prevProps.showQualityProfile) {
state.showQualityProfile = showQualityProfile;
}
if (showNextAlbum !== prevProps.showNextAlbum) {
state.showNextAlbum = showNextAlbum;
}
if (showSearchAction !== prevProps.showSearchAction) {
state.showSearchAction = showSearchAction;
}
if (!_.isEmpty(state)) {
this.setState(state);
}
}
//
// Listeners
onChangePosterOption = ({ name, value }) => {
this.setState({
[name]: value
}, () => {
this.props.onChangePosterOption({ [name]: value });
});
};
//
// Render
render() {
const {
onModalClose
} = this.props;
const {
detailedProgressBar,
size,
showTitle,
showMonitored,
showQualityProfile,
showNextAlbum,
showSearchAction
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Poster Options
</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>
{translate('PosterSize')}
</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="size"
value={size}
values={posterSizeOptions}
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('DetailedProgressBar')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="detailedProgressBar"
value={detailedProgressBar}
helpText={translate('DetailedProgressBarHelpText')}
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowName')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showTitle"
value={showTitle}
helpText={translate('ShowTitleHelpText')}
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowMonitored')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showMonitored"
value={showMonitored}
helpText={translate('ShowMonitoredHelpText')}
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowQualityProfile')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showQualityProfile"
value={showQualityProfile}
helpText={translate('ShowQualityProfileHelpText')}
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowNextAlbum')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showNextAlbum"
value={showNextAlbum}
helpText={translate('ShowNextAlbumHelpText')}
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowSearch')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText={translate('ShowSearchActionHelpText')}
onChange={this.onChangePosterOption}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
}
ArtistIndexPosterOptionsModalContent.propTypes = {
size: PropTypes.string.isRequired,
showTitle: PropTypes.bool.isRequired,
showMonitored: PropTypes.bool.isRequired,
showQualityProfile: PropTypes.bool.isRequired,
showNextAlbum: PropTypes.bool.isRequired,
detailedProgressBar: PropTypes.bool.isRequired,
showSearchAction: PropTypes.bool.isRequired,
onChangePosterOption: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default ArtistIndexPosterOptionsModalContent;

@ -0,0 +1,167 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import selectPosterOptions from 'Artist/Index/Posters/selectPosterOptions';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import 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 { setArtistPosterOption } from 'Store/Actions/artistIndexActions';
import translate from 'Utilities/String/translate';
const posterSizeOptions = [
{
key: 'small',
get value() {
return translate('Small');
},
},
{
key: 'medium',
get value() {
return translate('Medium');
},
},
{
key: 'large',
get value() {
return translate('Large');
},
},
];
interface ArtistIndexPosterOptionsModalContentProps {
onModalClose(...args: unknown[]): unknown;
}
function ArtistIndexPosterOptionsModalContent(
props: ArtistIndexPosterOptionsModalContentProps
) {
const { onModalClose } = props;
const posterOptions = useSelector(selectPosterOptions);
const {
detailedProgressBar,
size,
showTitle,
showMonitored,
showQualityProfile,
showNextAlbum,
showSearchAction,
} = posterOptions;
const dispatch = useDispatch();
const onPosterOptionChange = useCallback(
({ name, value }) => {
dispatch(setArtistPosterOption({ [name]: value }));
},
[dispatch]
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('PosterOptions')}</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>{translate('PosterSize')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="size"
value={size}
values={posterSizeOptions}
onChange={onPosterOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('DetailedProgressBar')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="detailedProgressBar"
value={detailedProgressBar}
helpText={translate('DetailedProgressBarHelpText')}
onChange={onPosterOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowName')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showTitle"
value={showTitle}
helpText={translate('ShowTitleHelpText')}
onChange={onPosterOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowMonitored')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showMonitored"
value={showMonitored}
helpText={translate('ShowMonitoredHelpText')}
onChange={onPosterOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowQualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showQualityProfile"
value={showQualityProfile}
helpText={translate('ShowQualityProfileHelpText')}
onChange={onPosterOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowNextAlbum')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showNextAlbum"
value={showNextAlbum}
helpText={translate('ShowNextAlbumHelpText')}
onChange={onPosterOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowSearch')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText={translate('ShowSearchActionHelpText')}
onChange={onPosterOptionChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default ArtistIndexPosterOptionsModalContent;

@ -1,23 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setArtistPosterOption } from 'Store/Actions/artistIndexActions';
import ArtistIndexPosterOptionsModalContent from './ArtistIndexPosterOptionsModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.artistIndex,
(artistIndex) => {
return artistIndex.posterOptions;
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onChangePosterOption(payload) {
dispatch(setArtistPosterOption(payload));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexPosterOptionsModalContent);

@ -0,0 +1,9 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
const selectPosterOptions = createSelector(
(state: AppState) => state.artistIndex.posterOptions,
(posterOptions) => posterOptions
);
export default selectPosterOptions;

@ -4,7 +4,6 @@
border-radius: 0;
background-color: #5b5b5b;
color: var(--white);
transition: width 200ms ease;
}
.progressBar {

@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import React from 'react';
import ProgressBar from 'Components/ProgressBar';
import { sizes } from 'Helpers/Props';
@ -6,7 +5,17 @@ import getProgressBarKind from 'Utilities/Artist/getProgressBarKind';
import translate from 'Utilities/String/translate';
import styles from './ArtistIndexProgressBar.css';
function ArtistIndexProgressBar(props) {
interface ArtistIndexProgressBarProps {
monitored: boolean;
status: string;
trackCount: number;
trackFileCount: number;
totalTrackCount: number;
posterWidth: number;
detailedProgressBar: boolean;
}
function ArtistIndexProgressBar(props: ArtistIndexProgressBarProps) {
const {
monitored,
status,
@ -14,10 +23,10 @@ function ArtistIndexProgressBar(props) {
trackFileCount,
totalTrackCount,
posterWidth,
detailedProgressBar
detailedProgressBar,
} = props;
const progress = trackCount ? trackFileCount / trackCount * 100 : 100;
const progress = trackCount ? (trackFileCount / trackCount) * 100 : 100;
const text = `${trackFileCount} / ${trackCount}`;
return (
@ -29,20 +38,14 @@ function ArtistIndexProgressBar(props) {
size={detailedProgressBar ? sizes.MEDIUM : sizes.SMALL}
showText={detailedProgressBar}
text={text}
title={translate('TrackFileCountTrackCountTotalTotalTrackCountInterp', [trackFileCount, trackCount, totalTrackCount])}
title={translate('ArtistProgressBarText', {
trackFileCount,
trackCount,
totalTrackCount,
})}
width={posterWidth}
/>
);
}
ArtistIndexProgressBar.propTypes = {
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
trackCount: PropTypes.number.isRequired,
trackFileCount: PropTypes.number.isRequired,
totalTrackCount: PropTypes.number.isRequired,
posterWidth: PropTypes.number.isRequired,
detailedProgressBar: PropTypes.bool.isRequired
};
export default ArtistIndexProgressBar;

@ -1,103 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
class ArtistIndexActionsCell extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: false
};
}
//
// Listeners
onEditArtistPress = () => {
this.setState({ isEditArtistModalOpen: true });
};
onEditArtistModalClose = () => {
this.setState({ isEditArtistModalOpen: false });
};
onDeleteArtistPress = () => {
this.setState({
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: true
});
};
onDeleteArtistModalClose = () => {
this.setState({ isDeleteArtistModalOpen: false });
};
//
// Render
render() {
const {
id,
isRefreshingArtist,
onRefreshArtistPress,
...otherProps
} = this.props;
const {
isEditArtistModalOpen,
isDeleteArtistModalOpen
} = this.state;
return (
<VirtualTableRowCell
{...otherProps}
>
<SpinnerIconButton
name={icons.REFRESH}
title={translate('RefreshArtist')}
isSpinning={isRefreshingArtist}
onPress={onRefreshArtistPress}
/>
<IconButton
name={icons.EDIT}
title={translate('EditArtist')}
onPress={this.onEditArtistPress}
/>
<EditArtistModalConnector
isOpen={isEditArtistModalOpen}
artistId={id}
onModalClose={this.onEditArtistModalClose}
onDeleteArtistPress={this.onDeleteArtistPress}
/>
<DeleteArtistModal
isOpen={isDeleteArtistModalOpen}
artistId={id}
onModalClose={this.onDeleteArtistModalClose}
/>
</VirtualTableRowCell>
);
}
}
ArtistIndexActionsCell.propTypes = {
id: PropTypes.number.isRequired,
isRefreshingArtist: PropTypes.bool.isRequired,
onRefreshArtistPress: PropTypes.func.isRequired
};
export default ArtistIndexActionsCell;

@ -1,86 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import IconButton from 'Components/Link/IconButton';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
import { icons } from 'Helpers/Props';
import ArtistIndexTableOptionsConnector from './ArtistIndexTableOptionsConnector';
import hasGrowableColumns from './hasGrowableColumns';
import styles from './ArtistIndexHeader.css';
function ArtistIndexHeader(props) {
const {
showBanners,
columns,
onTableOptionChange,
...otherProps
} = props;
return (
<VirtualTableHeader>
{
columns.map((column) => {
const {
name,
label,
isSortable,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'actions') {
return (
<VirtualTableHeaderCell
key={name}
className={styles[name]}
name={name}
isSortable={false}
{...otherProps}
>
<TableOptionsModalWrapper
columns={columns}
optionsComponent={ArtistIndexTableOptionsConnector}
onTableOptionChange={onTableOptionChange}
>
<IconButton
name={icons.ADVANCED_SETTINGS}
/>
</TableOptionsModalWrapper>
</VirtualTableHeaderCell>
);
}
return (
<VirtualTableHeaderCell
key={name}
className={classNames(
styles[name],
name === 'sortName' && showBanners && styles.banner,
name === 'sortName' && showBanners && !hasGrowableColumns(columns) && styles.bannerGrow
)}
name={name}
isSortable={isSortable}
{...otherProps}
>
{typeof label === 'function' ? label() : label}
</VirtualTableHeaderCell>
);
})
}
</VirtualTableHeader>
);
}
ArtistIndexHeader.propTypes = {
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onTableOptionChange: PropTypes.func.isRequired,
showBanners: PropTypes.bool.isRequired
};
export default ArtistIndexHeader;

@ -1,13 +0,0 @@
import { connect } from 'react-redux';
import { setArtistTableOption } from 'Store/Actions/artistIndexActions';
import ArtistIndexHeader from './ArtistIndexHeader';
function createMapDispatchToProps(dispatch, props) {
return {
onTableOptionChange(payload) {
dispatch(setArtistTableOption(payload));
}
};
}
export default connect(undefined, createMapDispatchToProps)(ArtistIndexHeader);

@ -1,487 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import AlbumTitleLink from 'Album/AlbumTitleLink';
import ArtistBanner from 'Artist/ArtistBanner';
import ArtistNameLink from 'Artist/ArtistNameLink';
import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
import HeartRating from 'Components/HeartRating';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ProgressBar from 'Components/ProgressBar';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import TagListConnector from 'Components/TagListConnector';
import { icons } from 'Helpers/Props';
import getProgressBarKind from 'Utilities/Artist/getProgressBarKind';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import ArtistStatusCell from './ArtistStatusCell';
import hasGrowableColumns from './hasGrowableColumns';
import styles from './ArtistIndexRow.css';
class ArtistIndexRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
hasBannerError: false,
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: false
};
}
onEditArtistPress = () => {
this.setState({ isEditArtistModalOpen: true });
};
onEditArtistModalClose = () => {
this.setState({ isEditArtistModalOpen: false });
};
onDeleteArtistPress = () => {
this.setState({
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: true
});
};
onDeleteArtistModalClose = () => {
this.setState({ isDeleteArtistModalOpen: false });
};
onUseSceneNumberingChange = () => {
// Mock handler to satisfy `onChange` being required for `CheckInput`.
//
};
onBannerLoad = () => {
if (this.state.hasBannerError) {
this.setState({ hasBannerError: false });
}
};
onBannerLoadError = () => {
if (!this.state.hasBannerError) {
this.setState({ hasBannerError: true });
}
};
//
// Render
render() {
const {
id,
monitored,
status,
artistName,
foreignArtistId,
artistType,
qualityProfile,
metadataProfile,
nextAlbum,
lastAlbum,
added,
statistics,
genres,
ratings,
path,
tags,
images,
isSaving,
showBanners,
showSearchAction,
columns,
isRefreshingArtist,
isSearchingArtist,
onRefreshArtistPress,
onSearchPress,
onMonitoredPress
} = this.props;
const {
albumCount,
trackCount,
trackFileCount,
totalTrackCount,
sizeOnDisk
} = statistics;
const {
hasBannerError,
isEditArtistModalOpen,
isDeleteArtistModalOpen
} = this.state;
return (
<>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'status') {
return (
<ArtistStatusCell
key={name}
className={styles[name]}
artistType={artistType}
monitored={monitored}
status={status}
isSaving={isSaving}
onMonitoredPress={onMonitoredPress}
component={VirtualTableRowCell}
/>
);
}
if (name === 'sortName') {
return (
<VirtualTableRowCell
key={name}
className={classNames(
styles[name],
showBanners && styles.banner,
showBanners && !hasGrowableColumns(columns) && styles.bannerGrow
)}
>
{
showBanners ?
<Link
className={styles.link}
to={`/artist/${foreignArtistId}`}
>
<ArtistBanner
className={styles.bannerImage}
images={images}
lazy={false}
overflow={true}
onError={this.onBannerLoadError}
onLoad={this.onBannerLoad}
/>
{
hasBannerError &&
<div className={styles.overlayTitle}>
{artistName}
</div>
}
</Link> :
<ArtistNameLink
foreignArtistId={foreignArtistId}
artistName={artistName}
/>
}
</VirtualTableRowCell>
);
}
if (name === 'artistType') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{artistType}
</VirtualTableRowCell>
);
}
if (name === 'qualityProfileId') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{qualityProfile.name}
</VirtualTableRowCell>
);
}
if (name === 'metadataProfileId') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{metadataProfile.name}
</VirtualTableRowCell>
);
}
if (name === 'nextAlbum') {
if (nextAlbum) {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<AlbumTitleLink
title={nextAlbum.title}
disambiguation={nextAlbum.disambiguation}
foreignAlbumId={nextAlbum.foreignAlbumId}
/>
</VirtualTableRowCell>
);
}
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
None
</VirtualTableRowCell>
);
}
if (name === 'lastAlbum') {
if (lastAlbum) {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<AlbumTitleLink
title={lastAlbum.title}
disambiguation={lastAlbum.disambiguation}
foreignAlbumId={lastAlbum.foreignAlbumId}
/>
</VirtualTableRowCell>
);
}
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
None
</VirtualTableRowCell>
);
}
if (name === 'added') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={added}
component={VirtualTableRowCell}
/>
);
}
if (name === 'albumCount') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{albumCount}
</VirtualTableRowCell>
);
}
if (name === 'trackProgress') {
const progress = trackCount ? trackFileCount / trackCount * 100 : 100;
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<ProgressBar
progress={progress}
kind={getProgressBarKind(status, monitored, progress)}
showText={true}
text={`${trackFileCount} / ${trackCount}`}
title={translate('TrackFileCountTrackCountTotalTotalTrackCountInterp', [trackFileCount, trackCount, totalTrackCount])}
width={125}
/>
</VirtualTableRowCell>
);
}
if (name === 'trackCount') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{totalTrackCount}
</VirtualTableRowCell>
);
}
if (name === 'path') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{path}
</VirtualTableRowCell>
);
}
if (name === 'sizeOnDisk') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{formatBytes(sizeOnDisk)}
</VirtualTableRowCell>
);
}
if (name === 'genres') {
const joinedGenres = genres.join(', ');
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<span title={joinedGenres}>
{joinedGenres}
</span>
</VirtualTableRowCell>
);
}
if (name === 'ratings') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<HeartRating
rating={ratings.value}
/>
</VirtualTableRowCell>
);
}
if (name === 'tags') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<TagListConnector
tags={tags}
/>
</VirtualTableRowCell>
);
}
if (name === 'actions') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<SpinnerIconButton
name={icons.REFRESH}
title={translate('RefreshArtist')}
isSpinning={isRefreshingArtist}
onPress={onRefreshArtistPress}
/>
{
showSearchAction &&
<SpinnerIconButton
className={styles.action}
name={icons.SEARCH}
title={translate('SearchForMonitoredAlbums')}
isSpinning={isSearchingArtist}
onPress={onSearchPress}
/>
}
<IconButton
name={icons.EDIT}
title={translate('EditArtist')}
onPress={this.onEditArtistPress}
/>
</VirtualTableRowCell>
);
}
return null;
})
}
<EditArtistModalConnector
isOpen={isEditArtistModalOpen}
artistId={id}
onModalClose={this.onEditArtistModalClose}
onDeleteArtistPress={this.onDeleteArtistPress}
/>
<DeleteArtistModal
isOpen={isDeleteArtistModalOpen}
artistId={id}
onModalClose={this.onDeleteArtistModalClose}
/>
</>
);
}
}
ArtistIndexRow.propTypes = {
id: PropTypes.number.isRequired,
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
artistName: PropTypes.string.isRequired,
foreignArtistId: PropTypes.string.isRequired,
artistType: PropTypes.string,
qualityProfile: PropTypes.object.isRequired,
metadataProfile: PropTypes.object.isRequired,
nextAlbum: PropTypes.object,
lastAlbum: PropTypes.object,
added: PropTypes.string,
statistics: PropTypes.object.isRequired,
latestAlbum: PropTypes.object,
path: PropTypes.string.isRequired,
genres: PropTypes.arrayOf(PropTypes.string).isRequired,
ratings: PropTypes.object.isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
isSaving: PropTypes.bool.isRequired,
showBanners: PropTypes.bool.isRequired,
showSearchAction: PropTypes.bool.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
isRefreshingArtist: PropTypes.bool.isRequired,
isSearchingArtist: PropTypes.bool.isRequired,
onRefreshArtistPress: PropTypes.func.isRequired,
onSearchPress: PropTypes.func.isRequired,
onMonitoredPress: PropTypes.func.isRequired
};
ArtistIndexRow.defaultProps = {
statistics: {
albumCount: 0,
trackCount: 0,
trackFileCount: 0,
totalTrackCount: 0
},
genres: [],
tags: []
};
export default ArtistIndexRow;

@ -0,0 +1,392 @@
import classNames from 'classnames';
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AlbumTitleLink from 'Album/AlbumTitleLink';
import { Statistics } from 'Artist/Artist';
import ArtistBanner from 'Artist/ArtistBanner';
import ArtistNameLink from 'Artist/ArtistNameLink';
import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
import createArtistIndexItemSelector from 'Artist/Index/createArtistIndexItemSelector';
import ArtistStatusCell from 'Artist/Index/Table/ArtistStatusCell';
import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames';
import HeartRating from 'Components/HeartRating';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ProgressBar from 'Components/ProgressBar';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import Column from 'Components/Table/Column';
import TagListConnector from 'Components/TagListConnector';
import { icons } from 'Helpers/Props';
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 hasGrowableColumns from './hasGrowableColumns';
import selectTableOptions from './selectTableOptions';
import styles from './ArtistIndexRow.css';
interface ArtistIndexRowProps {
artistId: number;
sortKey: string;
columns: Column[];
}
function ArtistIndexRow(props: ArtistIndexRowProps) {
const { artistId, columns } = props;
const {
artist,
qualityProfile,
metadataProfile,
isRefreshingArtist,
isSearchingArtist,
} = useSelector(createArtistIndexItemSelector(props.artistId));
const { showBanners, showSearchAction } = useSelector(selectTableOptions);
const {
artistName,
foreignArtistId,
monitored,
status,
path,
nextAlbum,
lastAlbum,
added,
statistics = {} as Statistics,
images,
artistType,
genres = [],
ratings,
tags = [],
isSaving = false,
} = artist;
const {
albumCount = 0,
trackCount = 0,
trackFileCount = 0,
totalTrackCount = 0,
sizeOnDisk = 0,
} = statistics;
const dispatch = useDispatch();
const [hasBannerError, setHasBannerError] = useState(false);
const [isEditArtistModalOpen, setIsEditArtistModalOpen] = useState(false);
const [isDeleteArtistModalOpen, setIsDeleteArtistModalOpen] = useState(false);
const onRefreshPress = useCallback(() => {
dispatch(
executeCommand({
name: REFRESH_ARTIST,
artistId,
})
);
}, [artistId, dispatch]);
const onSearchPress = useCallback(() => {
dispatch(
executeCommand({
name: ARTIST_SEARCH,
artistId,
})
);
}, [artistId, dispatch]);
const onBannerLoadError = useCallback(() => {
setHasBannerError(true);
}, [setHasBannerError]);
const onBannerLoad = useCallback(() => {
setHasBannerError(false);
}, [setHasBannerError]);
const onEditArtistPress = useCallback(() => {
setIsEditArtistModalOpen(true);
}, [setIsEditArtistModalOpen]);
const onEditArtistModalClose = useCallback(() => {
setIsEditArtistModalOpen(false);
}, [setIsEditArtistModalOpen]);
const onDeleteArtistPress = useCallback(() => {
setIsEditArtistModalOpen(false);
setIsDeleteArtistModalOpen(true);
}, [setIsDeleteArtistModalOpen]);
const onDeleteArtistModalClose = useCallback(() => {
setIsDeleteArtistModalOpen(false);
}, [setIsDeleteArtistModalOpen]);
return (
<>
{columns.map((column) => {
const { name, isVisible } = column;
if (!isVisible) {
return null;
}
if (name === 'status') {
return (
<ArtistStatusCell
key={name}
className={styles[name]}
artistId={artistId}
artistType={artistType}
monitored={monitored}
status={status}
isSaving={isSaving}
component={VirtualTableRowCell}
/>
);
}
if (name === 'sortName') {
return (
<VirtualTableRowCell
key={name}
className={classNames(
styles[name],
showBanners && styles.banner,
showBanners && !hasGrowableColumns(columns) && styles.bannerGrow
)}
>
{showBanners ? (
<Link className={styles.link} to={`/artist/${foreignArtistId}`}>
<ArtistBanner
className={styles.bannerImage}
images={images}
lazy={false}
overflow={true}
onError={onBannerLoadError}
onLoad={onBannerLoad}
/>
{hasBannerError && (
<div className={styles.overlayTitle}>{artistName}</div>
)}
</Link>
) : (
<ArtistNameLink
foreignArtistId={foreignArtistId}
artistName={artistName}
/>
)}
</VirtualTableRowCell>
);
}
if (name === 'artistType') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{artistType}
</VirtualTableRowCell>
);
}
if (name === 'qualityProfileId') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{qualityProfile.name}
</VirtualTableRowCell>
);
}
if (name === 'qualityProfileId') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{qualityProfile.name}
</VirtualTableRowCell>
);
}
if (name === 'metadataProfileId') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{metadataProfile.name}
</VirtualTableRowCell>
);
}
if (name === 'nextAlbum') {
if (nextAlbum) {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<AlbumTitleLink
title={nextAlbum.title}
disambiguation={nextAlbum.disambiguation}
foreignAlbumId={nextAlbum.foreignAlbumId}
/>
</VirtualTableRowCell>
);
}
return (
<VirtualTableRowCell key={name} className={styles[name]}>
None
</VirtualTableRowCell>
);
}
if (name === 'lastAlbum') {
if (lastAlbum) {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<AlbumTitleLink
title={lastAlbum.title}
disambiguation={lastAlbum.disambiguation}
foreignAlbumId={lastAlbum.foreignAlbumId}
/>
</VirtualTableRowCell>
);
}
return (
<VirtualTableRowCell key={name} className={styles[name]}>
None
</VirtualTableRowCell>
);
}
if (name === 'added') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={added}
component={VirtualTableRowCell}
/>
);
}
if (name === 'albumCount') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{albumCount}
</VirtualTableRowCell>
);
}
if (name === 'trackProgress') {
const progress = trackCount
? (trackFileCount / trackCount) * 100
: 100;
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<ProgressBar
progress={progress}
kind={getProgressBarKind(status, monitored, progress)}
showText={true}
text={`${trackFileCount} / ${trackCount}`}
title={translate('ArtistProgressBarText', {
trackFileCount,
trackCount,
totalTrackCount,
})}
width={125}
/>
</VirtualTableRowCell>
);
}
if (name === 'trackCount') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{totalTrackCount}
</VirtualTableRowCell>
);
}
if (name === 'path') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{path}
</VirtualTableRowCell>
);
}
if (name === 'sizeOnDisk') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{formatBytes(sizeOnDisk)}
</VirtualTableRowCell>
);
}
if (name === 'genres') {
const joinedGenres = genres.join(', ');
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<span title={joinedGenres}>{joinedGenres}</span>
</VirtualTableRowCell>
);
}
if (name === 'ratings') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<HeartRating rating={ratings.value} />
</VirtualTableRowCell>
);
}
if (name === 'tags') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<TagListConnector tags={tags} />
</VirtualTableRowCell>
);
}
if (name === 'actions') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<SpinnerIconButton
name={icons.REFRESH}
title={translate('RefreshArtist')}
isSpinning={isRefreshingArtist}
onPress={onRefreshPress}
/>
{showSearchAction ? (
<SpinnerIconButton
name={icons.SEARCH}
title={translate('SearchForMonitoredAlbums')}
isSpinning={isSearchingArtist}
onPress={onSearchPress}
/>
) : null}
<IconButton
name={icons.EDIT}
title={translate('EditArtist')}
onPress={onEditArtistPress}
/>
</VirtualTableRowCell>
);
}
return null;
})}
<EditArtistModalConnector
isOpen={isEditArtistModalOpen}
artistId={artistId}
onModalClose={onEditArtistModalClose}
onDeleteArtistPress={onDeleteArtistPress}
/>
<DeleteArtistModal
isOpen={isDeleteArtistModalOpen}
artistId={artistId}
onModalClose={onDeleteArtistModalClose}
/>
</>
);
}
export default ArtistIndexRow;

@ -1,5 +1,3 @@
.tableContainer {
composes: tableContainer from '~Components/Table/VirtualTable.css';
flex: 1 0 auto;
.tableScroller {
position: relative;
}

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

@ -1,134 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
import VirtualTable from 'Components/Table/VirtualTable';
import VirtualTableRow from 'Components/Table/VirtualTableRow';
import { sortDirections } from 'Helpers/Props';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import ArtistIndexHeaderConnector from './ArtistIndexHeaderConnector';
import ArtistIndexRow from './ArtistIndexRow';
import styles from './ArtistIndexTable.css';
class ArtistIndexTable extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
scrollIndex: null
};
}
componentDidUpdate(prevProps) {
const {
items,
jumpToCharacter
} = this.props;
if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) {
const scrollIndex = getIndexOfFirstCharacter(items, jumpToCharacter);
if (scrollIndex != null) {
this.setState({ scrollIndex });
}
} else if (jumpToCharacter == null && prevProps.jumpToCharacter != null) {
this.setState({ scrollIndex: null });
}
}
//
// Control
rowRenderer = ({ key, rowIndex, style }) => {
const {
items,
columns,
showBanners,
isSaving
} = this.props;
const artist = items[rowIndex];
return (
<VirtualTableRow
key={key}
style={style}
>
<ArtistIndexItemConnector
key={artist.id}
component={ArtistIndexRow}
style={style}
columns={columns}
artistId={artist.id}
qualityProfileId={artist.qualityProfileId}
metadataProfileId={artist.metadataProfileId}
showBanners={showBanners}
isSaving={isSaving}
/>
</VirtualTableRow>
);
};
//
// Render
render() {
const {
items,
columns,
sortKey,
sortDirection,
showBanners,
isSmallScreen,
onSortPress,
scroller,
scrollTop
} = this.props;
return (
<VirtualTable
className={styles.tableContainer}
items={items}
scrollIndex={this.state.scrollIndex}
scrollTop={scrollTop}
isSmallScreen={isSmallScreen}
scroller={scroller}
rowHeight={showBanners ? 70 : 38}
overscanRowCount={2}
rowRenderer={this.rowRenderer}
header={
<ArtistIndexHeaderConnector
showBanners={showBanners}
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
/>
}
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
/>
);
}
}
ArtistIndexTable.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
showBanners: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,
jumpToCharacter: PropTypes.string,
scrollTop: PropTypes.number,
scroller: PropTypes.instanceOf(Element).isRequired,
isSmallScreen: PropTypes.bool.isRequired,
onSortPress: PropTypes.func.isRequired
};
export default ArtistIndexTable;

@ -0,0 +1,205 @@
import { throttle } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import { createSelector } from 'reselect';
import Artist from 'Artist/Artist';
import ArtistIndexRow from 'Artist/Index/Table/ArtistIndexRow';
import ArtistIndexTableHeader from 'Artist/Index/Table/ArtistIndexTableHeader';
import Scroller from 'Components/Scroller/Scroller';
import Column from 'Components/Table/Column';
import useMeasure from 'Helpers/Hooks/useMeasure';
import ScrollDirection from 'Helpers/Props/ScrollDirection';
import SortDirection from 'Helpers/Props/SortDirection';
import dimensions from 'Styles/Variables/dimensions';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import selectTableOptions from './selectTableOptions';
import styles from './ArtistIndexTable.css';
const bodyPadding = parseInt(dimensions.pageContentBodyPadding);
const bodyPaddingSmallScreen = parseInt(
dimensions.pageContentBodyPaddingSmallScreen
);
interface RowItemData {
items: Artist[];
sortKey: string;
columns: Column[];
}
interface ArtistIndexTableProps {
items: Artist[];
sortKey?: string;
sortDirection?: SortDirection;
jumpToCharacter?: string;
scrollTop?: number;
scrollerRef: React.MutableRefObject<HTMLElement>;
isSmallScreen: boolean;
}
const columnsSelector = createSelector(
(state) => state.artistIndex.columns,
(columns) => columns
);
const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
index,
style,
data,
}) => {
const { items, sortKey, columns } = data;
if (index >= items.length) {
return null;
}
const artist = items[index];
return (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
...style,
}}
>
<ArtistIndexRow
artistId={artist.id}
sortKey={sortKey}
columns={columns}
/>
</div>
);
};
function getWindowScrollTopPosition() {
return document.documentElement.scrollTop || document.body.scrollTop || 0;
}
function ArtistIndexTable(props: ArtistIndexTableProps) {
const {
items,
sortKey,
sortDirection,
jumpToCharacter,
isSmallScreen,
scrollerRef,
} = props;
const columns = useSelector(columnsSelector);
const { showBanners } = useSelector(selectTableOptions);
const listRef: React.MutableRefObject<List> = useRef();
const [measureRef, bounds] = useMeasure();
const [size, setSize] = useState({ width: 0, height: 0 });
const rowHeight = useMemo(() => {
return showBanners ? 70 : 38;
}, [showBanners]);
useEffect(() => {
const current = scrollerRef.current as HTMLElement;
if (isSmallScreen) {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
return;
}
if (current) {
const width = current.clientWidth;
const padding =
(isSmallScreen ? bodyPaddingSmallScreen : bodyPadding) - 5;
setSize({
width: width - padding * 2,
height: window.innerHeight,
});
}
}, [isSmallScreen, scrollerRef, bounds]);
useEffect(() => {
const currentScrollListener = isSmallScreen ? window : scrollerRef.current;
const currentScrollerRef = scrollerRef.current;
const handleScroll = throttle(() => {
const { offsetTop = 0 } = currentScrollerRef;
const scrollTop =
(isSmallScreen
? getWindowScrollTopPosition()
: currentScrollerRef.scrollTop) - offsetTop;
listRef.current.scrollTo(scrollTop);
}, 10);
currentScrollListener.addEventListener('scroll', handleScroll);
return () => {
handleScroll.cancel();
if (currentScrollListener) {
currentScrollListener.removeEventListener('scroll', handleScroll);
}
};
}, [isSmallScreen, listRef, scrollerRef]);
useEffect(() => {
if (jumpToCharacter) {
const index = getIndexOfFirstCharacter(items, jumpToCharacter);
if (index != null) {
let scrollTop = index * rowHeight;
// If the offset is zero go to the top, otherwise offset
// by the approximate size of the header + padding (37 + 20).
if (scrollTop > 0) {
const offset = 57;
scrollTop += offset;
}
listRef.current.scrollTo(scrollTop);
scrollerRef.current.scrollTo(0, scrollTop);
}
}
}, [jumpToCharacter, rowHeight, items, scrollerRef, listRef]);
return (
<div ref={measureRef}>
<Scroller
className={styles.tableScroller}
scrollDirection={ScrollDirection.Horizontal}
>
<ArtistIndexTableHeader
showBanners={showBanners}
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
/>
<List<RowItemData>
ref={listRef}
style={{
width: '100%',
height: '100%',
overflow: 'none',
}}
width={size.width}
height={size.height}
itemCount={items.length}
itemSize={rowHeight}
itemData={{
items,
sortKey,
columns,
}}
>
{Row}
</List>
</Scroller>
</div>
);
}
export default ArtistIndexTable;

@ -1,29 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setArtistSort } from 'Store/Actions/artistIndexActions';
import ArtistIndexTable from './ArtistIndexTable';
function createMapStateToProps() {
return createSelector(
(state) => state.app.dimensions,
(state) => state.artistIndex.tableOptions,
(state) => state.artistIndex.columns,
(dimensions, tableOptions, columns) => {
return {
isSmallScreen: dimensions.isSmallScreen,
showBanners: tableOptions.showBanners,
columns
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onSortPress(sortKey) {
dispatch(setArtistSort({ sortKey }));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexTable);

@ -0,0 +1,98 @@
import classNames from 'classnames';
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import ArtistIndexTableOptions from 'Artist/Index/Table/ArtistIndexTableOptions';
import IconButton from 'Components/Link/IconButton';
import Column from 'Components/Table/Column';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
import { icons } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import {
setArtistSort,
setArtistTableOption,
} from 'Store/Actions/artistIndexActions';
import hasGrowableColumns from './hasGrowableColumns';
import styles from './ArtistIndexTableHeader.css';
interface ArtistIndexTableHeaderProps {
showBanners: boolean;
columns: Column[];
sortKey?: string;
sortDirection?: SortDirection;
}
function ArtistIndexTableHeader(props: ArtistIndexTableHeaderProps) {
const { showBanners, columns, sortKey, sortDirection } = props;
const dispatch = useDispatch();
const onSortPress = useCallback(
(value) => {
dispatch(setArtistSort({ sortKey: value }));
},
[dispatch]
);
const onTableOptionChange = useCallback(
(payload) => {
dispatch(setArtistTableOption(payload));
},
[dispatch]
);
return (
<VirtualTableHeader>
{columns.map((column) => {
const { name, label, isSortable, isVisible } = column;
if (!isVisible) {
return null;
}
if (name === 'actions') {
return (
<VirtualTableHeaderCell
key={name}
className={styles[name]}
name={name}
isSortable={false}
>
<TableOptionsModalWrapper
columns={columns}
optionsComponent={ArtistIndexTableOptions}
onTableOptionChange={onTableOptionChange}
>
<IconButton name={icons.ADVANCED_SETTINGS} />
</TableOptionsModalWrapper>
</VirtualTableHeaderCell>
);
}
return (
<VirtualTableHeaderCell
key={name}
className={classNames(
styles[name],
name === 'sortName' && showBanners && styles.banner,
name === 'sortName' &&
showBanners &&
!hasGrowableColumns(columns) &&
styles.bannerGrow
)}
name={name}
sortKey={sortKey}
sortDirection={sortDirection}
isSortable={isSortable}
onSortPress={onSortPress}
>
{typeof label === 'function' ? label() : label}
</VirtualTableHeaderCell>
);
})}
</VirtualTableHeader>
);
}
export default ArtistIndexTableHeader;

@ -1,105 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import { inputTypes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
class ArtistIndexTableOptions extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
showBanners: props.showBanners,
showSearchAction: props.showSearchAction
};
}
componentDidUpdate(prevProps) {
const {
showBanners,
showSearchAction
} = this.props;
if (
showBanners !== prevProps.showBanners ||
showSearchAction !== prevProps.showSearchAction
) {
this.setState({
showBanners,
showSearchAction
});
}
}
//
// Listeners
onTableOptionChange = ({ name, value }) => {
this.setState({
[name]: value
}, () => {
this.props.onTableOptionChange({
tableOptions: {
...this.state,
[name]: value
}
});
});
};
//
// Render
render() {
const {
showBanners,
showSearchAction
} = this.state;
return (
<Fragment>
<FormGroup>
<FormLabel>
{translate('ShowBanners')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showBanners"
value={showBanners}
helpText={translate('ShowBannersHelpText')}
onChange={this.onTableOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowSearch')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText={translate('ShowSearchActionHelpText')}
onChange={this.onTableOptionChange}
/>
</FormGroup>
</Fragment>
);
}
}
ArtistIndexTableOptions.propTypes = {
showBanners: PropTypes.bool.isRequired,
showSearchAction: PropTypes.bool.isRequired,
onTableOptionChange: PropTypes.func.isRequired
};
export default ArtistIndexTableOptions;

@ -0,0 +1,62 @@
import React, { Fragment, useCallback } from 'react';
import { useSelector } from 'react-redux';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import { inputTypes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import selectTableOptions from './selectTableOptions';
interface ArtistIndexTableOptionsProps {
onTableOptionChange(...args: unknown[]): unknown;
}
function ArtistIndexTableOptions(props: ArtistIndexTableOptionsProps) {
const { onTableOptionChange } = props;
const tableOptions = useSelector(selectTableOptions);
const { showBanners, showSearchAction } = tableOptions;
const onTableOptionChangeWrapper = useCallback(
({ name, value }) => {
onTableOptionChange({
tableOptions: {
...tableOptions,
[name]: value,
},
});
},
[tableOptions, onTableOptionChange]
);
return (
<Fragment>
<FormGroup>
<FormLabel>{translate('ShowBanners')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showBanners"
value={showBanners}
helpText={translate('ShowBannersHelpText')}
onChange={onTableOptionChangeWrapper}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowSearch')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText={translate('ShowSearchActionHelpText')}
onChange={onTableOptionChangeWrapper}
/>
</FormGroup>
</Fragment>
);
}
export default ArtistIndexTableOptions;

@ -1,14 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import ArtistIndexTableOptions from './ArtistIndexTableOptions';
function createMapStateToProps() {
return createSelector(
(state) => state.artistIndex.tableOptions,
(tableOptions) => {
return tableOptions;
}
);
}
export default connect(createMapStateToProps)(ArtistIndexTableOptions);

@ -1,31 +1,44 @@
import PropTypes from 'prop-types';
import React from 'react';
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import Icon from 'Components/Icon';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell';
import { icons } from 'Helpers/Props';
import { toggleArtistMonitored } from 'Store/Actions/artistActions';
import translate from 'Utilities/String/translate';
import styles from './ArtistStatusCell.css';
function ArtistStatusCell(props) {
interface ArtistStatusCellProps {
className: string;
artistId: number;
artistType?: string;
monitored: boolean;
status: string;
isSaving: boolean;
component?: React.ElementType;
}
function ArtistStatusCell(props: ArtistStatusCellProps) {
const {
className,
artistId,
artistType,
monitored,
status,
isSaving,
onMonitoredPress,
component: Component,
component: Component = VirtualTableRowCell,
...otherProps
} = props;
const endedString = artistType === 'Person' ? 'Deceased' : 'Inactive';
const dispatch = useDispatch();
const onMonitoredPress = useCallback(() => {
dispatch(toggleArtistMonitored({ artistId, monitored: !monitored }));
}, [artistId, monitored, dispatch]);
return (
<Component
className={className}
{...otherProps}
>
<Component className={className} {...otherProps}>
<MonitorToggleButton
className={styles.monitorToggle}
monitored={monitored}
@ -37,25 +50,12 @@ function ArtistStatusCell(props) {
<Icon
className={styles.statusIcon}
name={status === 'ended' ? icons.ARTIST_ENDED : icons.ARTIST_CONTINUING}
title={status === 'ended' ? endedString : translate('StatusEndedContinuing')}
title={
status === 'ended' ? endedString : translate('StatusEndedContinuing')
}
/>
</Component>
);
}
ArtistStatusCell.propTypes = {
className: PropTypes.string.isRequired,
artistType: PropTypes.string,
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
isSaving: PropTypes.bool.isRequired,
onMonitoredPress: PropTypes.func.isRequired,
component: PropTypes.elementType
};
ArtistStatusCell.defaultProps = {
className: styles.status,
component: VirtualTableRowCell
};
export default ArtistStatusCell;

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

@ -0,0 +1,11 @@
import Column from 'Components/Table/Column';
const growableColumns = ['qualityProfileId', 'path', 'tags'];
export default function hasGrowableColumns(columns: Column[]) {
return columns.some((column) => {
const { name, isVisible } = column;
return growableColumns.includes(name) && isVisible;
});
}

@ -0,0 +1,9 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
const selectTableOptions = createSelector(
(state: AppState) => state.artistIndex.tableOptions,
(tableOptions) => tableOptions
);
export default selectTableOptions;

@ -0,0 +1,48 @@
import { createSelector } from 'reselect';
import Artist from 'Artist/Artist';
import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames';
import createArtistMetadataProfileSelector from 'Store/Selectors/createArtistMetadataProfileSelector';
import createArtistQualityProfileSelector from 'Store/Selectors/createArtistQualityProfileSelector';
import { createArtistSelectorForHook } from 'Store/Selectors/createArtistSelector';
import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector';
function createArtistIndexItemSelector(artistId: number) {
return createSelector(
createArtistSelectorForHook(artistId),
createArtistQualityProfileSelector(artistId),
createArtistMetadataProfileSelector(artistId),
createExecutingCommandsSelector(),
(artist: Artist, qualityProfile, metadataProfile, executingCommands) => {
// If an artist is deleted this selector may fire before the parent
// selectors, which will result in an undefined artist, if that happens
// we want to return early here and again in the render function to avoid
// trying to show an artist that has no information available.
if (!artist) {
return {};
}
const isRefreshingArtist = executingCommands.some((command) => {
return (
command.name === REFRESH_ARTIST && command.body.artistId === artist.id
);
});
const isSearchingArtist = executingCommands.some((command) => {
return (
command.name === ARTIST_SEARCH && command.body.artistId === artist.id
);
});
return {
artist,
qualityProfile,
metadataProfile,
isRefreshingArtist,
isSearchingArtist,
};
}
);
}
export default createArtistIndexItemSelector;

@ -23,6 +23,8 @@ function SpinnerIconButton(props) {
}
SpinnerIconButton.propTypes = {
...IconButton.propTypes,
className: PropTypes.string,
name: PropTypes.object.isRequired,
spinningName: PropTypes.object.isRequired,
isDisabled: PropTypes.bool.isRequired,

@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react';
import Menu from 'Components/Menu/Menu';
import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
import { icons } from 'Helpers/Props';
import { align, icons } from 'Helpers/Props';
function SortMenu(props) {
const {
@ -30,7 +30,8 @@ function SortMenu(props) {
SortMenu.propTypes = {
className: PropTypes.string,
children: PropTypes.node.isRequired,
isDisabled: PropTypes.bool.isRequired
isDisabled: PropTypes.bool.isRequired,
alignMenu: PropTypes.oneOf([align.LEFT, align.RIGHT])
};
SortMenu.defaultProps = {

@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react';
import Menu from 'Components/Menu/Menu';
import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
import { icons } from 'Helpers/Props';
import { align, icons } from 'Helpers/Props';
function ViewMenu(props) {
const {
@ -27,7 +27,8 @@ function ViewMenu(props) {
ViewMenu.propTypes = {
children: PropTypes.node.isRequired,
isDisabled: PropTypes.bool.isRequired
isDisabled: PropTypes.bool.isRequired,
alignMenu: PropTypes.oneOf([align.LEFT, align.RIGHT])
};
ViewMenu.defaultProps = {

@ -1,61 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Scroller from 'Components/Scroller/Scroller';
import { scrollDirections } from 'Helpers/Props';
import { isLocked } from 'Utilities/scrollLock';
import styles from './PageContentBody.css';
class PageContentBody extends Component {
//
// Listeners
onScroll = (props) => {
const { onScroll } = this.props;
if (this.props.onScroll && !isLocked()) {
onScroll(props);
}
};
//
// Render
render() {
const {
className,
innerClassName,
children,
dispatch,
...otherProps
} = this.props;
return (
<Scroller
className={className}
scrollDirection={scrollDirections.VERTICAL}
{...otherProps}
onScroll={this.onScroll}
>
<div className={innerClassName}>
{children}
</div>
</Scroller>
);
}
}
PageContentBody.propTypes = {
className: PropTypes.string,
innerClassName: PropTypes.string,
children: PropTypes.node.isRequired,
onScroll: PropTypes.func,
dispatch: PropTypes.func
};
PageContentBody.defaultProps = {
className: styles.contentBody,
innerClassName: styles.innerContentBody
};
export default PageContentBody;

@ -0,0 +1,51 @@
import React, { forwardRef, ReactNode, useCallback } from 'react';
import Scroller from 'Components/Scroller/Scroller';
import ScrollDirection from 'Helpers/Props/ScrollDirection';
import { isLocked } from 'Utilities/scrollLock';
import styles from './PageContentBody.css';
interface PageContentBodyProps {
className?: string;
innerClassName?: string;
children: ReactNode;
initialScrollTop?: number;
onScroll?: (payload) => void;
}
const PageContentBody = forwardRef(
(
props: PageContentBodyProps,
ref: React.MutableRefObject<HTMLDivElement>
) => {
const {
className = styles.contentBody,
innerClassName = styles.innerContentBody,
children,
onScroll,
...otherProps
} = props;
const onScrollWrapper = useCallback(
(payload) => {
if (onScroll && !isLocked()) {
onScroll(payload);
}
},
[onScroll]
);
return (
<Scroller
ref={ref}
{...otherProps}
className={className}
scrollDirection={ScrollDirection.Vertical}
onScroll={onScrollWrapper}
>
<div className={innerClassName}>{children}</div>
</Scroller>
);
}
);
export default PageContentBody;

@ -45,7 +45,8 @@ PageToolbarButton.propTypes = {
iconName: PropTypes.object.isRequired,
spinningName: PropTypes.object,
isSpinning: PropTypes.bool,
isDisabled: PropTypes.bool
isDisabled: PropTypes.bool,
onPress: PropTypes.func
};
PageToolbarButton.defaultProps = {

@ -1,95 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { scrollDirections } from 'Helpers/Props';
import styles from './Scroller.css';
class Scroller extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._scroller = null;
}
componentDidMount() {
const {
scrollDirection,
autoFocus,
scrollTop
} = this.props;
if (this.props.scrollTop != null) {
this._scroller.scrollTop = scrollTop;
}
if (autoFocus && scrollDirection !== scrollDirections.NONE) {
this._scroller.focus({ preventScroll: true });
}
}
//
// Control
_setScrollerRef = (ref) => {
this._scroller = ref;
this.props.registerScroller(ref);
};
//
// Render
render() {
const {
className,
scrollDirection,
autoScroll,
children,
scrollTop,
onScroll,
registerScroller,
...otherProps
} = this.props;
return (
<div
ref={this._setScrollerRef}
className={classNames(
className,
styles.scroller,
styles[scrollDirection],
autoScroll && styles.autoScroll
)}
tabIndex={-1}
{...otherProps}
>
{children}
</div>
);
}
}
Scroller.propTypes = {
className: PropTypes.string,
scrollDirection: PropTypes.oneOf(scrollDirections.all).isRequired,
autoFocus: PropTypes.bool.isRequired,
autoScroll: PropTypes.bool.isRequired,
scrollTop: PropTypes.number,
children: PropTypes.node,
onScroll: PropTypes.func,
registerScroller: PropTypes.func
};
Scroller.defaultProps = {
scrollDirection: scrollDirections.VERTICAL,
autoFocus: true,
autoScroll: true,
registerScroller: () => { /* no-op */ }
};
export default Scroller;

@ -0,0 +1,90 @@
import classNames from 'classnames';
import { throttle } from 'lodash';
import React, { forwardRef, ReactNode, useEffect, useRef } from 'react';
import ScrollDirection from 'Helpers/Props/ScrollDirection';
import styles from './Scroller.css';
interface ScrollerProps {
className?: string;
scrollDirection?: ScrollDirection;
autoFocus?: boolean;
autoScroll?: boolean;
scrollTop?: number;
initialScrollTop?: number;
children?: ReactNode;
onScroll?: (payload) => void;
}
const Scroller = forwardRef(
(props: ScrollerProps, ref: React.MutableRefObject<HTMLDivElement>) => {
const {
className,
autoFocus = false,
autoScroll = true,
scrollDirection = ScrollDirection.Vertical,
children,
scrollTop,
initialScrollTop,
onScroll,
...otherProps
} = props;
const internalRef = useRef();
const currentRef = ref ?? internalRef;
useEffect(
() => {
if (initialScrollTop != null) {
currentRef.current.scrollTop = initialScrollTop;
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
useEffect(() => {
if (scrollTop != null) {
currentRef.current.scrollTop = scrollTop;
}
if (autoFocus && scrollDirection !== ScrollDirection.None) {
currentRef.current.focus({ preventScroll: true });
}
}, [autoFocus, currentRef, scrollDirection, scrollTop]);
useEffect(() => {
const div = currentRef.current;
const handleScroll = throttle(() => {
const scrollLeft = div.scrollLeft;
const scrollTop = div.scrollTop;
onScroll?.({ scrollLeft, scrollTop });
}, 10);
div.addEventListener('scroll', handleScroll);
return () => {
div.removeEventListener('scroll', handleScroll);
};
}, [currentRef, onScroll]);
return (
<div
{...otherProps}
ref={currentRef}
className={classNames(
className,
styles.scroller,
styles[scrollDirection],
autoScroll && styles.autoScroll
)}
tabIndex={-1}
>
{children}
</div>
);
}
);
export default Scroller;

@ -1,30 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import scrollPositions from 'Store/scrollPositions';
function withScrollPosition(WrappedComponent, scrollPositionKey) {
function ScrollPosition(props) {
const {
history
} = props;
const scrollTop = history.action === 'POP' || (history.location.state && history.location.state.restoreScrollPosition) ?
scrollPositions[scrollPositionKey] :
0;
return (
<WrappedComponent
{...props}
scrollTop={scrollTop}
/>
);
}
ScrollPosition.propTypes = {
history: PropTypes.object.isRequired
};
return ScrollPosition;
}
export default withScrollPosition;

@ -0,0 +1,25 @@
import PropTypes from 'prop-types';
import React from 'react';
import scrollPositions from 'Store/scrollPositions';
function withScrollPosition(WrappedComponent, scrollPositionKey) {
function ScrollPosition(props) {
const { history } = props;
const initialScrollTop =
history.action === 'POP' ||
(history.location.state && history.location.state.restoreScrollPosition)
? scrollPositions[scrollPositionKey]
: 0;
return <WrappedComponent {...props} initialScrollTop={initialScrollTop} />;
}
ScrollPosition.propTypes = {
history: PropTypes.object.isRequired,
};
return ScrollPosition;
}
export default withScrollPosition;

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

Loading…
Cancel
Save