diff --git a/frontend/src/App/SelectContext.tsx b/frontend/src/App/SelectContext.tsx new file mode 100644 index 000000000..05ee42791 --- /dev/null +++ b/frontend/src/App/SelectContext.tsx @@ -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; + +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 { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + children: any; + isSelectMode: boolean; + items: Array; +} + +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( + props: SelectProviderOptions +) { + 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 ( + + {props.children} + + ); +} + +export function useSelect() { + const context = React.useContext(SelectContext); + + if (context === undefined) { + throw new Error('useSelect must be used within a SelectProvider'); + } + + return context; +} diff --git a/frontend/src/Artist/Index/ArtistIndex.tsx b/frontend/src/Artist/Index/ArtistIndex.tsx index 604b905a2..345ce10e7 100644 --- a/frontend/src/Artist/Index/ArtistIndex.tsx +++ b/frontend/src/Artist/Index/ArtistIndex.tsx @@ -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(); const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false); const [jumpToCharacter, setJumpToCharacter] = useState(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 ( - - - - + + + + + - - - - - {view === 'table' ? ( - - - - ) : ( - )} - + - + - + {isSelectMode ? : null} + + + + {view === 'table' ? ( + + + + ) : ( + + )} - - - -
- - {isFetching && !isPopulated ? : null} - - {!isFetching && !!error ? ( -
- {getErrorMessage(error, 'Failed to load artist from API')} -
- ) : null} + - {isLoaded ? ( -
- + - -
- ) : null} + - {!error && isPopulated && !items.length ? ( - + + + +
+ + {isFetching && !isPopulated ? : null} + + {!isFetching && !!error ? ( +
+ {getErrorMessage(error, 'Failed to load artist from API')} +
+ ) : null} + + {isLoaded ? ( +
+ + + +
+ ) : null} + + {!error && isPopulated && !items.length ? ( + + ) : null} +
+ + {isLoaded && !!jumpBarItems.order.length ? ( + ) : null} - - - {isLoaded && !!jumpBarItems.order.length ? ( - +
+ {view === 'posters' ? ( + + ) : null} + {view === 'banners' ? ( + + ) : null} + {view === 'overview' ? ( + ) : null} -
- {view === 'posters' ? ( - - ) : null} - {view === 'banners' ? ( - - ) : null} - {view === 'overview' ? ( - - ) : null} -
+
+ ); }, 'artistIndex'); diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx index 1ab5171ad..d59ea0187 100644 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx @@ -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 (
+ {isSelectMode ? : null} +