mirror of
https://github.com/lidarr/Lidarr
synced 2025-03-15 08:19:05 +00:00
Added artists index selection
(cherry picked from commit 815a16d5cfced17ca4db7f1b66991c5cc9f3b719)
This commit is contained in:
parent
d31c323f3c
commit
84d5f2bcee
17 changed files with 553 additions and 135 deletions
170
frontend/src/App/SelectContext.tsx
Normal file
170
frontend/src/App/SelectContext.tsx
Normal file
|
@ -0,0 +1,170 @@
|
|||
import { cloneDeep } from 'lodash';
|
||||
import React, { useEffect } from 'react';
|
||||
import areAllSelected from 'Utilities/Table/areAllSelected';
|
||||
import selectAll from 'Utilities/Table/selectAll';
|
||||
import toggleSelected from 'Utilities/Table/toggleSelected';
|
||||
import ModelBase from './ModelBase';
|
||||
|
||||
export enum SelectActionType {
|
||||
Reset,
|
||||
SelectAll,
|
||||
UnselectAll,
|
||||
ToggleSelected,
|
||||
RemoveItem,
|
||||
UpdateItems,
|
||||
}
|
||||
|
||||
type SelectedState = Record<number, boolean>;
|
||||
|
||||
interface SelectState {
|
||||
selectedState: SelectedState;
|
||||
lastToggled: number | null;
|
||||
allSelected: boolean;
|
||||
allUnselected: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
items: any[];
|
||||
}
|
||||
|
||||
type SelectAction =
|
||||
| { type: SelectActionType.Reset }
|
||||
| { type: SelectActionType.SelectAll }
|
||||
| { type: SelectActionType.UnselectAll }
|
||||
| {
|
||||
type: SelectActionType.ToggleSelected;
|
||||
id: number;
|
||||
isSelected: boolean;
|
||||
shiftKey: boolean;
|
||||
}
|
||||
| {
|
||||
type: SelectActionType.RemoveItem;
|
||||
id: number;
|
||||
}
|
||||
| {
|
||||
type: SelectActionType.UpdateItems;
|
||||
items: ModelBase[];
|
||||
};
|
||||
|
||||
type Dispatch = (action: SelectAction) => void;
|
||||
|
||||
const initialState = {
|
||||
selectedState: {},
|
||||
lastToggled: null,
|
||||
allSelected: false,
|
||||
allUnselected: true,
|
||||
items: [],
|
||||
};
|
||||
|
||||
interface SelectProviderOptions<T extends ModelBase> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
children: any;
|
||||
isSelectMode: boolean;
|
||||
items: Array<T>;
|
||||
}
|
||||
|
||||
function getSelectedState(items: ModelBase[], existingState: SelectedState) {
|
||||
return items.reduce((acc: SelectedState, item) => {
|
||||
const id = item.id;
|
||||
|
||||
acc[id] = existingState[id] ?? false;
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// TODO: Can this be reused?
|
||||
|
||||
const SelectContext = React.createContext<[SelectState, Dispatch] | undefined>(
|
||||
cloneDeep(undefined)
|
||||
);
|
||||
|
||||
function selectReducer(state: SelectState, action: SelectAction): SelectState {
|
||||
const { items, selectedState } = state;
|
||||
|
||||
switch (action.type) {
|
||||
case SelectActionType.Reset: {
|
||||
return cloneDeep(initialState);
|
||||
}
|
||||
case SelectActionType.SelectAll: {
|
||||
return {
|
||||
items,
|
||||
...selectAll(selectedState, true),
|
||||
};
|
||||
}
|
||||
case SelectActionType.UnselectAll: {
|
||||
return {
|
||||
items,
|
||||
...selectAll(selectedState, false),
|
||||
};
|
||||
}
|
||||
case SelectActionType.ToggleSelected: {
|
||||
var result = {
|
||||
items,
|
||||
...toggleSelected(
|
||||
state,
|
||||
items,
|
||||
action.id,
|
||||
action.isSelected,
|
||||
action.shiftKey
|
||||
),
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
case SelectActionType.UpdateItems: {
|
||||
const nextSelectedState = getSelectedState(action.items, selectedState);
|
||||
|
||||
return {
|
||||
...state,
|
||||
...areAllSelected(nextSelectedState),
|
||||
selectedState: nextSelectedState,
|
||||
items,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unhandled action type: ${action.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function SelectProvider<T extends ModelBase>(
|
||||
props: SelectProviderOptions<T>
|
||||
) {
|
||||
const { isSelectMode, items } = props;
|
||||
const selectedState = getSelectedState(items, {});
|
||||
|
||||
const [state, dispatch] = React.useReducer(selectReducer, {
|
||||
selectedState,
|
||||
lastToggled: null,
|
||||
allSelected: false,
|
||||
allUnselected: true,
|
||||
items,
|
||||
});
|
||||
|
||||
const value: [SelectState, Dispatch] = [state, dispatch];
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSelectMode) {
|
||||
dispatch({ type: SelectActionType.Reset });
|
||||
}
|
||||
}, [isSelectMode]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: SelectActionType.UpdateItems, items });
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<SelectContext.Provider value={value}>
|
||||
{props.children}
|
||||
</SelectContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSelect() {
|
||||
const context = React.useContext(SelectContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error('useSelect must be used within a SelectProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { SelectProvider } from 'App/SelectContext';
|
||||
import NoArtist from 'Artist/NoArtist';
|
||||
import { REFRESH_ARTIST, RSS_SYNC } from 'Commands/commandNames';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
|
@ -37,6 +38,7 @@ import ArtistIndexOverviews from './Overview/ArtistIndexOverviews';
|
|||
import ArtistIndexOverviewOptionsModal from './Overview/Options/ArtistIndexOverviewOptionsModal';
|
||||
import ArtistIndexPosters from './Posters/ArtistIndexPosters';
|
||||
import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal';
|
||||
import ArtistIndexSelectAllButton from './Select/ArtistIndexSelectAllButton';
|
||||
import ArtistIndexTable from './Table/ArtistIndexTable';
|
||||
import ArtistIndexTableOptions from './Table/ArtistIndexTableOptions';
|
||||
import styles from './ArtistIndex.css';
|
||||
|
@ -88,6 +90,7 @@ const ArtistIndex = withScrollPosition((props: ArtistIndexProps) => {
|
|||
const scrollerRef = useRef<HTMLDivElement>();
|
||||
const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false);
|
||||
const [jumpToCharacter, setJumpToCharacter] = useState<string | null>(null);
|
||||
const [isSelectMode, setIsSelectMode] = useState(false);
|
||||
|
||||
const onRefreshArtistPress = useCallback(() => {
|
||||
dispatch(
|
||||
|
@ -105,6 +108,10 @@ const ArtistIndex = withScrollPosition((props: ArtistIndexProps) => {
|
|||
);
|
||||
}, [dispatch]);
|
||||
|
||||
const onSelectModePress = useCallback(() => {
|
||||
setIsSelectMode(!isSelectMode);
|
||||
}, [isSelectMode, setIsSelectMode]);
|
||||
|
||||
const onTableOptionChange = useCallback(
|
||||
(payload) => {
|
||||
dispatch(setArtistTableOption(payload));
|
||||
|
@ -202,131 +209,150 @@ const ArtistIndex = withScrollPosition((props: ArtistIndexProps) => {
|
|||
const hasNoArtist = !totalItems;
|
||||
|
||||
return (
|
||||
<PageContent>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('UpdateAll')}
|
||||
iconName={icons.REFRESH}
|
||||
spinningName={icons.REFRESH}
|
||||
isSpinning={isRefreshingArtist}
|
||||
isDisabled={hasNoArtist}
|
||||
onPress={onRefreshArtistPress}
|
||||
/>
|
||||
<SelectProvider isSelectMode={isSelectMode} items={items}>
|
||||
<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>
|
||||
<PageToolbarButton
|
||||
label={translate('RSSSync')}
|
||||
iconName={icons.RSS}
|
||||
isSpinning={isRssSyncExecuting}
|
||||
isDisabled={hasNoArtist}
|
||||
onPress={onRssSyncPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT} collapseButtons={false}>
|
||||
{view === 'table' ? (
|
||||
<TableOptionsModalWrapper
|
||||
columns={columns}
|
||||
optionsComponent={ArtistIndexTableOptions}
|
||||
onTableOptionChange={onTableOptionChange}
|
||||
>
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={isSelectMode ? 'Stop Selecting' : 'Select Artists'}
|
||||
iconName={isSelectMode ? icons.ARTIST_ENDED : icons.CHECK}
|
||||
onPress={onSelectModePress}
|
||||
/>
|
||||
|
||||
{isSelectMode ? <ArtistIndexSelectAllButton /> : null}
|
||||
</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={icons.TABLE}
|
||||
iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW}
|
||||
isDisabled={hasNoArtist}
|
||||
onPress={onOptionsPress}
|
||||
/>
|
||||
</TableOptionsModalWrapper>
|
||||
) : (
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW}
|
||||
)}
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<ArtistIndexViewMenu
|
||||
view={view}
|
||||
isDisabled={hasNoArtist}
|
||||
onPress={onOptionsPress}
|
||||
onViewSelect={onViewSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PageToolbarSeparator />
|
||||
<ArtistIndexSortMenu
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
isDisabled={hasNoArtist}
|
||||
onSortSelect={onSortSelect}
|
||||
/>
|
||||
|
||||
<ArtistIndexViewMenu
|
||||
view={view}
|
||||
isDisabled={hasNoArtist}
|
||||
onViewSelect={onViewSelect}
|
||||
/>
|
||||
<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}
|
||||
|
||||
<ArtistIndexSortMenu
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
isDisabled={hasNoArtist}
|
||||
onSortSelect={onSortSelect}
|
||||
/>
|
||||
{!isFetching && !!error ? (
|
||||
<div className={styles.errorMessage}>
|
||||
{getErrorMessage(error, 'Failed to load artist from API')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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}
|
||||
{isLoaded ? (
|
||||
<div className={styles.contentBodyContainer}>
|
||||
<ViewComponent
|
||||
scrollerRef={scrollerRef}
|
||||
items={items}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
jumpToCharacter={jumpToCharacter}
|
||||
isSelectMode={isSelectMode}
|
||||
isSmallScreen={isSmallScreen}
|
||||
/>
|
||||
|
||||
{!isFetching && !!error ? (
|
||||
<div className={styles.errorMessage}>
|
||||
{getErrorMessage(error, 'Failed to load artist from API')}
|
||||
</div>
|
||||
<ArtistIndexFooter />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!error && isPopulated && !items.length ? (
|
||||
<NoArtist totalItems={totalItems} />
|
||||
) : null}
|
||||
</PageContentBody>
|
||||
|
||||
{isLoaded && !!jumpBarItems.order.length ? (
|
||||
<PageJumpBar
|
||||
items={jumpBarItems}
|
||||
onItemPress={onJumpBarItemPress}
|
||||
/>
|
||||
) : 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} />
|
||||
</div>
|
||||
{view === 'posters' ? (
|
||||
<ArtistIndexPosterOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={onOptionsModalClose}
|
||||
/>
|
||||
) : 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>
|
||||
{view === 'banners' ? (
|
||||
<ArtistIndexBannerOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={onOptionsModalClose}
|
||||
/>
|
||||
) : null}
|
||||
{view === 'overview' ? (
|
||||
<ArtistIndexOverviewOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={onOptionsModalClose}
|
||||
/>
|
||||
) : null}
|
||||
</PageContent>
|
||||
</SelectProvider>
|
||||
);
|
||||
}, 'artistIndex');
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ 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 ArtistIndexPosterSelect from 'Artist/Index/Select/ArtistIndexPosterSelect';
|
||||
import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames';
|
||||
import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
|
@ -23,12 +24,13 @@ import styles from './ArtistIndexBanner.css';
|
|||
interface ArtistIndexBannerProps {
|
||||
artistId: number;
|
||||
sortKey: string;
|
||||
isSelectMode: boolean;
|
||||
bannerWidth: number;
|
||||
bannerHeight: number;
|
||||
}
|
||||
|
||||
function ArtistIndexBanner(props: ArtistIndexBannerProps) {
|
||||
const { artistId, sortKey, bannerWidth, bannerHeight } = props;
|
||||
const { artistId, sortKey, isSelectMode, bannerWidth, bannerHeight } = props;
|
||||
|
||||
const {
|
||||
artist,
|
||||
|
@ -130,6 +132,8 @@ function ArtistIndexBanner(props: ArtistIndexBannerProps) {
|
|||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.bannerContainer}>
|
||||
{isSelectMode ? <ArtistIndexPosterSelect artistId={artistId} /> : null}
|
||||
|
||||
<Label className={styles.controls}>
|
||||
<SpinnerIconButton
|
||||
className={styles.action}
|
||||
|
|
|
@ -36,6 +36,7 @@ interface CellItemData {
|
|||
};
|
||||
items: Artist[];
|
||||
sortKey: string;
|
||||
isSelectMode: boolean;
|
||||
}
|
||||
|
||||
interface ArtistIndexBannersProps {
|
||||
|
@ -45,6 +46,7 @@ interface ArtistIndexBannersProps {
|
|||
jumpToCharacter?: string;
|
||||
scrollTop?: number;
|
||||
scrollerRef: React.MutableRefObject<HTMLElement>;
|
||||
isSelectMode: boolean;
|
||||
isSmallScreen: boolean;
|
||||
}
|
||||
|
||||
|
@ -63,10 +65,8 @@ const Cell: React.FC<GridChildComponentProps<CellItemData>> = ({
|
|||
style,
|
||||
data,
|
||||
}) => {
|
||||
const { layout, items, sortKey } = data;
|
||||
|
||||
const { layout, items, sortKey, isSelectMode } = data;
|
||||
const { columnCount, padding, bannerWidth, bannerHeight } = layout;
|
||||
|
||||
const index = rowIndex * columnCount + columnIndex;
|
||||
|
||||
if (index >= items.length) {
|
||||
|
@ -85,6 +85,7 @@ const Cell: React.FC<GridChildComponentProps<CellItemData>> = ({
|
|||
<ArtistIndexBanner
|
||||
artistId={artist.id}
|
||||
sortKey={sortKey}
|
||||
isSelectMode={isSelectMode}
|
||||
bannerWidth={bannerWidth}
|
||||
bannerHeight={bannerHeight}
|
||||
/>
|
||||
|
@ -97,7 +98,14 @@ function getWindowScrollTopPosition() {
|
|||
}
|
||||
|
||||
export default function ArtistIndexBanners(props: ArtistIndexBannersProps) {
|
||||
const { scrollerRef, items, sortKey, jumpToCharacter, isSmallScreen } = props;
|
||||
const {
|
||||
scrollerRef,
|
||||
items,
|
||||
sortKey,
|
||||
jumpToCharacter,
|
||||
isSelectMode,
|
||||
isSmallScreen,
|
||||
} = props;
|
||||
|
||||
const { bannerOptions } = useSelector(artistIndexSelector);
|
||||
const ref: React.MutableRefObject<Grid> = useRef();
|
||||
|
@ -285,6 +293,7 @@ export default function ArtistIndexBanners(props: ArtistIndexBannersProps) {
|
|||
},
|
||||
items,
|
||||
sortKey,
|
||||
isSelectMode,
|
||||
}}
|
||||
>
|
||||
{Cell}
|
||||
|
|
|
@ -6,6 +6,7 @@ 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 ArtistIndexPosterSelect from 'Artist/Index/Select/ArtistIndexPosterSelect';
|
||||
import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import Link from 'Components/Link/Link';
|
||||
|
@ -37,6 +38,7 @@ interface ArtistIndexOverviewProps {
|
|||
posterWidth: number;
|
||||
posterHeight: number;
|
||||
rowHeight: number;
|
||||
isSelectMode: boolean;
|
||||
isSmallScreen: boolean;
|
||||
}
|
||||
|
||||
|
@ -47,6 +49,7 @@ function ArtistIndexOverview(props: ArtistIndexOverviewProps) {
|
|||
posterWidth,
|
||||
posterHeight,
|
||||
rowHeight,
|
||||
isSelectMode,
|
||||
isSmallScreen,
|
||||
} = props;
|
||||
|
||||
|
@ -136,6 +139,10 @@ function ArtistIndexOverview(props: ArtistIndexOverviewProps) {
|
|||
<div className={styles.content}>
|
||||
<div className={styles.poster}>
|
||||
<div className={styles.posterContainer}>
|
||||
{isSelectMode ? (
|
||||
<ArtistIndexPosterSelect artistId={artistId} />
|
||||
) : null}
|
||||
|
||||
{status === 'ended' && (
|
||||
<div className={styles.ended} title={translate('Inactive')} />
|
||||
)}
|
||||
|
|
|
@ -27,6 +27,7 @@ interface RowItemData {
|
|||
posterWidth: number;
|
||||
posterHeight: number;
|
||||
rowHeight: number;
|
||||
isSelectMode: boolean;
|
||||
isSmallScreen: boolean;
|
||||
}
|
||||
|
||||
|
@ -37,6 +38,7 @@ interface ArtistIndexOverviewsProps {
|
|||
jumpToCharacter?: string;
|
||||
scrollTop?: number;
|
||||
scrollerRef: React.MutableRefObject<HTMLElement>;
|
||||
isSelectMode: boolean;
|
||||
isSmallScreen: boolean;
|
||||
}
|
||||
|
||||
|
@ -65,7 +67,14 @@ function getWindowScrollTopPosition() {
|
|||
}
|
||||
|
||||
function ArtistIndexOverviews(props: ArtistIndexOverviewsProps) {
|
||||
const { items, sortKey, jumpToCharacter, isSmallScreen, scrollerRef } = props;
|
||||
const {
|
||||
items,
|
||||
sortKey,
|
||||
jumpToCharacter,
|
||||
scrollerRef,
|
||||
isSelectMode,
|
||||
isSmallScreen,
|
||||
} = props;
|
||||
|
||||
const { size: posterSize, detailedProgressBar } = useSelector(
|
||||
selectOverviewOptions
|
||||
|
@ -191,6 +200,7 @@ function ArtistIndexOverviews(props: ArtistIndexOverviewsProps) {
|
|||
posterWidth,
|
||||
posterHeight,
|
||||
rowHeight,
|
||||
isSelectMode,
|
||||
isSmallScreen,
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -7,6 +7,7 @@ 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 ArtistIndexPosterSelect from 'Artist/Index/Select/ArtistIndexPosterSelect';
|
||||
import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames';
|
||||
import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
|
@ -23,12 +24,13 @@ import styles from './ArtistIndexPoster.css';
|
|||
interface ArtistIndexPosterProps {
|
||||
artistId: number;
|
||||
sortKey: string;
|
||||
isSelectMode: boolean;
|
||||
posterWidth: number;
|
||||
posterHeight: number;
|
||||
}
|
||||
|
||||
function ArtistIndexPoster(props: ArtistIndexPosterProps) {
|
||||
const { artistId, sortKey, posterWidth, posterHeight } = props;
|
||||
const { artistId, sortKey, isSelectMode, posterWidth, posterHeight } = props;
|
||||
|
||||
const {
|
||||
artist,
|
||||
|
@ -130,6 +132,8 @@ function ArtistIndexPoster(props: ArtistIndexPosterProps) {
|
|||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.posterContainer}>
|
||||
{isSelectMode ? <ArtistIndexPosterSelect artistId={artistId} /> : null}
|
||||
|
||||
<Label className={styles.controls}>
|
||||
<SpinnerIconButton
|
||||
className={styles.action}
|
||||
|
|
|
@ -36,6 +36,7 @@ interface CellItemData {
|
|||
};
|
||||
items: Artist[];
|
||||
sortKey: string;
|
||||
isSelectMode: boolean;
|
||||
}
|
||||
|
||||
interface ArtistIndexPostersProps {
|
||||
|
@ -45,6 +46,7 @@ interface ArtistIndexPostersProps {
|
|||
jumpToCharacter?: string;
|
||||
scrollTop?: number;
|
||||
scrollerRef: React.MutableRefObject<HTMLElement>;
|
||||
isSelectMode: boolean;
|
||||
isSmallScreen: boolean;
|
||||
}
|
||||
|
||||
|
@ -63,10 +65,8 @@ const Cell: React.FC<GridChildComponentProps<CellItemData>> = ({
|
|||
style,
|
||||
data,
|
||||
}) => {
|
||||
const { layout, items, sortKey } = data;
|
||||
|
||||
const { layout, items, sortKey, isSelectMode } = data;
|
||||
const { columnCount, padding, posterWidth, posterHeight } = layout;
|
||||
|
||||
const index = rowIndex * columnCount + columnIndex;
|
||||
|
||||
if (index >= items.length) {
|
||||
|
@ -85,6 +85,7 @@ const Cell: React.FC<GridChildComponentProps<CellItemData>> = ({
|
|||
<ArtistIndexPoster
|
||||
artistId={artist.id}
|
||||
sortKey={sortKey}
|
||||
isSelectMode={isSelectMode}
|
||||
posterWidth={posterWidth}
|
||||
posterHeight={posterHeight}
|
||||
/>
|
||||
|
@ -97,7 +98,14 @@ function getWindowScrollTopPosition() {
|
|||
}
|
||||
|
||||
export default function ArtistIndexPosters(props: ArtistIndexPostersProps) {
|
||||
const { scrollerRef, items, sortKey, jumpToCharacter, isSmallScreen } = props;
|
||||
const {
|
||||
scrollerRef,
|
||||
items,
|
||||
sortKey,
|
||||
jumpToCharacter,
|
||||
isSelectMode,
|
||||
isSmallScreen,
|
||||
} = props;
|
||||
|
||||
const { posterOptions } = useSelector(artistIndexSelector);
|
||||
const ref: React.MutableRefObject<Grid> = useRef();
|
||||
|
@ -285,6 +293,7 @@ export default function ArtistIndexPosters(props: ArtistIndexPostersProps) {
|
|||
},
|
||||
items,
|
||||
sortKey,
|
||||
isSelectMode,
|
||||
}}
|
||||
>
|
||||
{Cell}
|
||||
|
|
36
frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.css
Normal file
36
frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.css
Normal file
|
@ -0,0 +1,36 @@
|
|||
.checkContainer {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 3;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--defaultColor);
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
}
|
||||
|
||||
.selected {
|
||||
composes: icon;
|
||||
|
||||
color: var(--lidarrGreen);
|
||||
|
||||
&:hover {
|
||||
color: var(--white);
|
||||
}
|
||||
}
|
||||
|
||||
.unselected {
|
||||
composes: icon;
|
||||
|
||||
color: var(--white);
|
||||
|
||||
&:hover {
|
||||
color: var(--lidarrGreen);
|
||||
}
|
||||
}
|
10
frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.css.d.ts
vendored
Normal file
10
frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.css.d.ts
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'checkContainer': string;
|
||||
'icon': string;
|
||||
'selected': string;
|
||||
'unselected': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
41
frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.tsx
Normal file
41
frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { SelectActionType, useSelect } from 'App/SelectContext';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import styles from './ArtistIndexPosterSelect.css';
|
||||
|
||||
interface ArtistIndexPosterSelectProps {
|
||||
artistId: number;
|
||||
}
|
||||
|
||||
function ArtistIndexPosterSelect(props: ArtistIndexPosterSelectProps) {
|
||||
const { artistId } = props;
|
||||
const [selectState, selectDispatch] = useSelect();
|
||||
const isSelected = selectState.selectedState[artistId];
|
||||
|
||||
const onSelectPress = useCallback(
|
||||
(event) => {
|
||||
const shiftKey = event.nativeEvent.shiftKey;
|
||||
|
||||
selectDispatch({
|
||||
type: SelectActionType.ToggleSelected,
|
||||
id: artistId,
|
||||
isSelected: !isSelected,
|
||||
shiftKey,
|
||||
});
|
||||
},
|
||||
[artistId, isSelected, selectDispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
className={styles.checkContainer}
|
||||
iconClassName={isSelected ? styles.selected : styles.unselected}
|
||||
name={isSelected ? icons.CHECK_CIRCLE : icons.CIRCLE_OUTLINE}
|
||||
size={20}
|
||||
onPress={onSelectPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ArtistIndexPosterSelect;
|
|
@ -0,0 +1,35 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { SelectActionType, useSelect } from 'App/SelectContext';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
|
||||
function ArtistIndexSelectAllButton() {
|
||||
const [selectState, selectDispatch] = useSelect();
|
||||
const { allSelected, allUnselected } = selectState;
|
||||
|
||||
let icon = icons.SQUARE_MINUS;
|
||||
|
||||
if (allSelected) {
|
||||
icon = icons.CHECK_SQUARE;
|
||||
} else if (allUnselected) {
|
||||
icon = icons.SQUARE;
|
||||
}
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
selectDispatch({
|
||||
type: allSelected
|
||||
? SelectActionType.UnselectAll
|
||||
: SelectActionType.SelectAll,
|
||||
});
|
||||
}, [allSelected, selectDispatch]);
|
||||
|
||||
return (
|
||||
<PageToolbarButton
|
||||
label={allSelected ? 'Unselect All' : 'Select All'}
|
||||
iconName={icon}
|
||||
onPress={onPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ArtistIndexSelectAllButton;
|
|
@ -2,6 +2,7 @@ import classNames from 'classnames';
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AlbumTitleLink from 'Album/AlbumTitleLink';
|
||||
import { SelectActionType, useSelect } from 'App/SelectContext';
|
||||
import { Statistics } from 'Artist/Artist';
|
||||
import ArtistBanner from 'Artist/ArtistBanner';
|
||||
import ArtistNameLink from 'Artist/ArtistNameLink';
|
||||
|
@ -17,6 +18,7 @@ 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 VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
|
||||
import Column from 'Components/Table/Column';
|
||||
import TagListConnector from 'Components/TagListConnector';
|
||||
import { icons } from 'Helpers/Props';
|
||||
|
@ -32,10 +34,11 @@ interface ArtistIndexRowProps {
|
|||
artistId: number;
|
||||
sortKey: string;
|
||||
columns: Column[];
|
||||
isSelectMode: boolean;
|
||||
}
|
||||
|
||||
function ArtistIndexRow(props: ArtistIndexRowProps) {
|
||||
const { artistId, columns } = props;
|
||||
const { artistId, columns, isSelectMode } = props;
|
||||
|
||||
const {
|
||||
artist,
|
||||
|
@ -77,6 +80,7 @@ function ArtistIndexRow(props: ArtistIndexRowProps) {
|
|||
const [hasBannerError, setHasBannerError] = useState(false);
|
||||
const [isEditArtistModalOpen, setIsEditArtistModalOpen] = useState(false);
|
||||
const [isDeleteArtistModalOpen, setIsDeleteArtistModalOpen] = useState(false);
|
||||
const [selectState, selectDispatch] = useSelect();
|
||||
|
||||
const onRefreshPress = useCallback(() => {
|
||||
dispatch(
|
||||
|
@ -121,8 +125,29 @@ function ArtistIndexRow(props: ArtistIndexRowProps) {
|
|||
setIsDeleteArtistModalOpen(false);
|
||||
}, [setIsDeleteArtistModalOpen]);
|
||||
|
||||
const onSelectedChange = useCallback(
|
||||
({ id, value, shiftKey }) => {
|
||||
selectDispatch({
|
||||
type: SelectActionType.ToggleSelected,
|
||||
id,
|
||||
isSelected: value,
|
||||
shiftKey,
|
||||
});
|
||||
},
|
||||
[selectDispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSelectMode ? (
|
||||
<VirtualTableSelectCell
|
||||
id={artistId}
|
||||
isSelected={selectState.selectedState[artistId]}
|
||||
isDisabled={false}
|
||||
onSelectedChange={onSelectedChange}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{columns.map((column) => {
|
||||
const { name, isVisible } = column;
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ interface RowItemData {
|
|||
items: Artist[];
|
||||
sortKey: string;
|
||||
columns: Column[];
|
||||
isSelectMode: boolean;
|
||||
}
|
||||
|
||||
interface ArtistIndexTableProps {
|
||||
|
@ -34,6 +35,7 @@ interface ArtistIndexTableProps {
|
|||
jumpToCharacter?: string;
|
||||
scrollTop?: number;
|
||||
scrollerRef: React.MutableRefObject<HTMLElement>;
|
||||
isSelectMode: boolean;
|
||||
isSmallScreen: boolean;
|
||||
}
|
||||
|
||||
|
@ -47,7 +49,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
|
|||
style,
|
||||
data,
|
||||
}) => {
|
||||
const { items, sortKey, columns } = data;
|
||||
const { items, sortKey, columns, isSelectMode } = data;
|
||||
|
||||
if (index >= items.length) {
|
||||
return null;
|
||||
|
@ -67,6 +69,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
|
|||
artistId={artist.id}
|
||||
sortKey={sortKey}
|
||||
columns={columns}
|
||||
isSelectMode={isSelectMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -82,6 +85,7 @@ function ArtistIndexTable(props: ArtistIndexTableProps) {
|
|||
sortKey,
|
||||
sortDirection,
|
||||
jumpToCharacter,
|
||||
isSelectMode,
|
||||
isSmallScreen,
|
||||
scrollerRef,
|
||||
} = props;
|
||||
|
@ -177,6 +181,7 @@ function ArtistIndexTable(props: ArtistIndexTableProps) {
|
|||
columns={columns}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
isSelectMode={isSelectMode}
|
||||
/>
|
||||
<List<RowItemData>
|
||||
ref={listRef}
|
||||
|
@ -193,6 +198,7 @@ function ArtistIndexTable(props: ArtistIndexTableProps) {
|
|||
items,
|
||||
sortKey,
|
||||
columns,
|
||||
isSelectMode,
|
||||
}}
|
||||
>
|
||||
{Row}
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { SelectActionType, useSelect } from 'App/SelectContext';
|
||||
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 VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import SortDirection from 'Helpers/Props/SortDirection';
|
||||
import {
|
||||
|
@ -21,12 +23,13 @@ interface ArtistIndexTableHeaderProps {
|
|||
columns: Column[];
|
||||
sortKey?: string;
|
||||
sortDirection?: SortDirection;
|
||||
isSelectMode: boolean;
|
||||
}
|
||||
|
||||
function ArtistIndexTableHeader(props: ArtistIndexTableHeaderProps) {
|
||||
const { showBanners, columns, sortKey, sortDirection } = props;
|
||||
|
||||
const { showBanners, columns, sortKey, sortDirection, isSelectMode } = props;
|
||||
const dispatch = useDispatch();
|
||||
const [selectState, selectDispatch] = useSelect();
|
||||
|
||||
const onSortPress = useCallback(
|
||||
(value) => {
|
||||
|
@ -42,8 +45,25 @@ function ArtistIndexTableHeader(props: ArtistIndexTableHeaderProps) {
|
|||
[dispatch]
|
||||
);
|
||||
|
||||
const onSelectAllChange = useCallback(
|
||||
({ value }) => {
|
||||
selectDispatch({
|
||||
type: value ? SelectActionType.SelectAll : SelectActionType.UnselectAll,
|
||||
});
|
||||
},
|
||||
[selectDispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<VirtualTableHeader>
|
||||
{isSelectMode ? (
|
||||
<VirtualTableSelectAllHeaderCell
|
||||
allSelected={selectState.allSelected}
|
||||
allUnselected={selectState.allUnselected}
|
||||
onSelectAllChange={onSelectAllChange}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{columns.map((column) => {
|
||||
const { name, label, isSortable, isVisible } = column;
|
||||
|
||||
|
|
|
@ -15,7 +15,8 @@ import {
|
|||
faHdd as farHdd,
|
||||
faKeyboard as farKeyboard,
|
||||
faObjectGroup as farObjectGroup,
|
||||
faObjectUngroup as farObjectUngroup
|
||||
faObjectUngroup as farObjectUngroup,
|
||||
faSquare as farSquare
|
||||
} from '@fortawesome/free-regular-svg-icons';
|
||||
//
|
||||
// Solid
|
||||
|
@ -90,6 +91,8 @@ import {
|
|||
faSortDown as fasSortDown,
|
||||
faSortUp as fasSortUp,
|
||||
faSpinner as fasSpinner,
|
||||
faSquareCheck as fasSquareCheck,
|
||||
faSquareMinus as fasSquareMinus,
|
||||
faStar as fasStar,
|
||||
faStop as fasStop,
|
||||
faSync as fasSync,
|
||||
|
@ -128,6 +131,7 @@ export const CARET_DOWN = fasCaretDown;
|
|||
export const CHECK = fasCheck;
|
||||
export const CHECK_INDETERMINATE = fasMinus;
|
||||
export const CHECK_CIRCLE = fasCheckCircle;
|
||||
export const CHECK_SQUARE = fasSquareCheck;
|
||||
export const CIRCLE = fasCircle;
|
||||
export const CIRCLE_OUTLINE = farCircle;
|
||||
export const CLEAR = fasTrashAlt;
|
||||
|
@ -205,6 +209,8 @@ export const SORT = fasSort;
|
|||
export const SORT_ASCENDING = fasSortUp;
|
||||
export const SORT_DESCENDING = fasSortDown;
|
||||
export const SPINNER = fasSpinner;
|
||||
export const SQUARE = farSquare;
|
||||
export const SQUARE_MINUS = fasSquareMinus;
|
||||
export const STAR_FULL = fasStar;
|
||||
export const SUBTRACT = fasMinus;
|
||||
export const SYSTEM = fasLaptop;
|
||||
|
|
|
@ -1,29 +1,29 @@
|
|||
import areAllSelected from './areAllSelected';
|
||||
import getToggledRange from './getToggledRange';
|
||||
|
||||
function toggleSelected(state, items, id, selected, shiftKey) {
|
||||
const lastToggled = state.lastToggled;
|
||||
const selectedState = {
|
||||
...state.selectedState,
|
||||
function toggleSelected(selectedState, items, id, selected, shiftKey) {
|
||||
const lastToggled = selectedState.lastToggled;
|
||||
const nextSelectedState = {
|
||||
...selectedState.selectedState,
|
||||
[id]: selected
|
||||
};
|
||||
|
||||
if (selected == null) {
|
||||
delete selectedState[id];
|
||||
delete nextSelectedState[id];
|
||||
}
|
||||
|
||||
if (shiftKey && lastToggled) {
|
||||
const { lower, upper } = getToggledRange(items, id, lastToggled);
|
||||
|
||||
for (let i = lower; i < upper; i++) {
|
||||
selectedState[items[i].id] = selected;
|
||||
nextSelectedState[items[i].id] = selected;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...areAllSelected(selectedState),
|
||||
...areAllSelected(nextSelectedState),
|
||||
lastToggled: id,
|
||||
selectedState
|
||||
selectedState: nextSelectedState
|
||||
};
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue