Added movie index selection

Author: Mark McDowall <mark@mcdowall.ca>
pull/8414/head
Robin Dadswell 2 years ago committed by Qstick
parent df26229e4d
commit ee5fed8522

@ -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;
}

@ -15,7 +15,8 @@ import {
faHdd as farHdd, faHdd as farHdd,
faKeyboard as farKeyboard, faKeyboard as farKeyboard,
faObjectGroup as farObjectGroup, faObjectGroup as farObjectGroup,
faObjectUngroup as farObjectUngroup faObjectUngroup as farObjectUngroup,
faSquare as farSquare
} from '@fortawesome/free-regular-svg-icons'; } from '@fortawesome/free-regular-svg-icons';
// //
// Solid // Solid
@ -37,7 +38,6 @@ import {
faCaretDown as fasCaretDown, faCaretDown as fasCaretDown,
faCheck as fasCheck, faCheck as fasCheck,
faCheckCircle as fasCheckCircle, faCheckCircle as fasCheckCircle,
faCheckSquare as fasCheckSquare,
faChevronCircleDown as fasChevronCircleDown, faChevronCircleDown as fasChevronCircleDown,
faChevronCircleRight as fasChevronCircleRight, faChevronCircleRight as fasChevronCircleRight,
faChevronCircleUp as fasChevronCircleUp, faChevronCircleUp as fasChevronCircleUp,
@ -91,6 +91,8 @@ import {
faSortDown as fasSortDown, faSortDown as fasSortDown,
faSortUp as fasSortUp, faSortUp as fasSortUp,
faSpinner as fasSpinner, faSpinner as fasSpinner,
faSquareCheck as fasSquareCheck,
faSquareMinus as fasSquareMinus,
faStop as fasStop, faStop as fasStop,
faSync as fasSync, faSync as fasSync,
faTable as fasTable, faTable as fasTable,
@ -128,7 +130,7 @@ export const CARET_DOWN = fasCaretDown;
export const CHECK = fasCheck; export const CHECK = fasCheck;
export const CHECK_INDETERMINATE = fasMinus; export const CHECK_INDETERMINATE = fasMinus;
export const CHECK_CIRCLE = fasCheckCircle; export const CHECK_CIRCLE = fasCheckCircle;
export const CHECK_SQUARE = fasCheckSquare; export const CHECK_SQUARE = fasSquareCheck;
export const CIRCLE = fasCircle; export const CIRCLE = fasCircle;
export const CIRCLE_OUTLINE = farCircle; export const CIRCLE_OUTLINE = farCircle;
export const CLEAR = fasTrashAlt; export const CLEAR = fasTrashAlt;
@ -211,6 +213,8 @@ export const SORT = fasSort;
export const SORT_ASCENDING = fasSortUp; export const SORT_ASCENDING = fasSortUp;
export const SORT_DESCENDING = fasSortDown; export const SORT_DESCENDING = fasSortDown;
export const SPINNER = fasSpinner; export const SPINNER = fasSpinner;
export const SQUARE = farSquare;
export const SQUARE_MINUS = fasSquareMinus;
export const STUDIO = fasBuilding; export const STUDIO = fasBuilding;
export const SUBTRACT = fasMinus; export const SUBTRACT = fasMinus;
export const SYSTEM = fasLaptop; export const SYSTEM = fasLaptop;

@ -1,5 +1,6 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'; import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { SelectProvider } from 'App/SelectContext';
import { REFRESH_MOVIE, RSS_SYNC } from 'Commands/commandNames'; import { REFRESH_MOVIE, RSS_SYNC } from 'Commands/commandNames';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent'; import PageContent from 'Components/Page/PageContent';
@ -25,6 +26,7 @@ import scrollPositions from 'Store/scrollPositions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createMovieClientSideCollectionItemsSelector from 'Store/Selectors/createMovieClientSideCollectionItemsSelector'; import createMovieClientSideCollectionItemsSelector from 'Store/Selectors/createMovieClientSideCollectionItemsSelector';
import translate from 'Utilities/String/translate';
import MovieIndexFilterMenu from './Menus/MovieIndexFilterMenu'; import MovieIndexFilterMenu from './Menus/MovieIndexFilterMenu';
import MovieIndexSortMenu from './Menus/MovieIndexSortMenu'; import MovieIndexSortMenu from './Menus/MovieIndexSortMenu';
import MovieIndexViewMenu from './Menus/MovieIndexViewMenu'; import MovieIndexViewMenu from './Menus/MovieIndexViewMenu';
@ -33,6 +35,7 @@ import MovieIndexOverviews from './Overview/MovieIndexOverviews';
import MovieIndexOverviewOptionsModal from './Overview/Options/MovieIndexOverviewOptionsModal'; import MovieIndexOverviewOptionsModal from './Overview/Options/MovieIndexOverviewOptionsModal';
import MovieIndexPosters from './Posters/MovieIndexPosters'; import MovieIndexPosters from './Posters/MovieIndexPosters';
import MovieIndexPosterOptionsModal from './Posters/Options/MovieIndexPosterOptionsModal'; import MovieIndexPosterOptionsModal from './Posters/Options/MovieIndexPosterOptionsModal';
import MovieIndexSelectAllButton from './Select/MovieIndexSelectAllButton';
import MovieIndexTable from './Table/MovieIndexTable'; import MovieIndexTable from './Table/MovieIndexTable';
import MovieIndexTableOptions from './Table/MovieIndexTableOptions'; import MovieIndexTableOptions from './Table/MovieIndexTableOptions';
import styles from './MovieIndex.css'; import styles from './MovieIndex.css';
@ -53,7 +56,7 @@ interface MovieIndexProps {
initialScrollTop?: number; initialScrollTop?: number;
} }
const MovieIndex = withScrollPosition((props) => { const MovieIndex = withScrollPosition((props: MovieIndexProps) => {
const { const {
isFetching, isFetching,
isPopulated, isPopulated,
@ -80,6 +83,7 @@ const MovieIndex = withScrollPosition((props) => {
const scrollerRef = useRef<HTMLDivElement>(); const scrollerRef = useRef<HTMLDivElement>();
const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false); const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false);
const [jumpToCharacter, setJumpToCharacter] = useState<string | null>(null); const [jumpToCharacter, setJumpToCharacter] = useState<string | null>(null);
const [isSelectMode, setIsSelectMode] = useState(false);
const onRefreshMoviePress = useCallback(() => { const onRefreshMoviePress = useCallback(() => {
dispatch( dispatch(
@ -89,6 +93,10 @@ const MovieIndex = withScrollPosition((props) => {
); );
}, [dispatch]); }, [dispatch]);
const onSelectModePress = useCallback(() => {
setIsSelectMode(!isSelectMode);
}, [isSelectMode, setIsSelectMode]);
const onRssSyncPress = useCallback(() => { const onRssSyncPress = useCallback(() => {
dispatch( dispatch(
executeCommand({ executeCommand({
@ -194,118 +202,144 @@ const MovieIndex = withScrollPosition((props) => {
const hasNoMovie = !totalItems; const hasNoMovie = !totalItems;
return ( return (
<PageContent> <SelectProvider isSelectMode={isSelectMode} items={items}>
<PageToolbar> <PageContent>
<PageToolbarSection> <PageToolbar>
<PageToolbarButton <PageToolbarSection>
label="Update all" <PageToolbarButton
iconName={icons.REFRESH} label={translate('UpdateAll')}
spinningName={icons.REFRESH} iconName={icons.REFRESH}
isSpinning={isRefreshingMovie} spinningName={icons.REFRESH}
isDisabled={hasNoMovie} isSpinning={isRefreshingMovie}
onPress={onRefreshMoviePress} isDisabled={hasNoMovie}
/> onPress={onRefreshMoviePress}
/>
<PageToolbarButton
label="RSS Sync"
iconName={icons.RSS}
isSpinning={isRssSyncExecuting}
isDisabled={hasNoMovie}
onPress={onRssSyncPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT} collapseButtons={false}>
{view === 'table' ? (
<TableOptionsModalWrapper
columns={columns}
optionsComponent={MovieIndexTableOptions}
onTableOptionChange={onTableOptionChange}
>
<PageToolbarButton label="Options" iconName={icons.TABLE} />
</TableOptionsModalWrapper>
) : (
<PageToolbarButton <PageToolbarButton
label="Options" label={translate('RSSSync')}
iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW} iconName={icons.RSS}
isSpinning={isRssSyncExecuting}
isDisabled={hasNoMovie} isDisabled={hasNoMovie}
onPress={onOptionsPress} onPress={onRssSyncPress}
/> />
)}
<PageToolbarSeparator /> <PageToolbarSeparator />
<MovieIndexViewMenu <PageToolbarButton
view={view} label={
isDisabled={hasNoMovie} isSelectMode
onViewSelect={onViewSelect} ? translate('StopSelecting')
/> : translate('SelectMovie')
}
<MovieIndexSortMenu iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK}
sortKey={sortKey} onPress={onSelectModePress}
sortDirection={sortDirection} />
isDisabled={hasNoMovie}
onSortSelect={onSortSelect}
/>
<MovieIndexFilterMenu {isSelectMode ? <MovieIndexSelectAllButton /> : null}
selectedFilterKey={selectedFilterKey} </PageToolbarSection>
filters={filters}
customFilters={customFilters} <PageToolbarSection
isDisabled={hasNoMovie} alignContent={align.RIGHT}
onFilterSelect={onFilterSelect} collapseButtons={false}
/> >
</PageToolbarSection> {view === 'table' ? (
</PageToolbar> <TableOptionsModalWrapper
<div className={styles.pageContentBodyWrapper}> columns={columns}
<PageContentBody optionsComponent={MovieIndexTableOptions}
ref={scrollerRef} onTableOptionChange={onTableOptionChange}
className={styles.contentBody} >
innerClassName={styles[`${view}InnerContentBody`]} <PageToolbarButton
initialScrollTop={props.initialScrollTop} label={translate('Options')}
onScroll={onScroll} iconName={icons.TABLE}
> />
{isFetching && !isPopulated ? <LoadingIndicator /> : null} </TableOptionsModalWrapper>
) : (
{!isFetching && !!error ? <div>Unable to load movie</div> : null} <PageToolbarButton
label={translate('Options')}
{isLoaded ? ( iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW}
<div className={styles.contentBodyContainer}> isDisabled={hasNoMovie}
<ViewComponent onPress={onOptionsPress}
scrollerRef={scrollerRef}
items={items}
sortKey={sortKey}
sortDirection={sortDirection}
jumpToCharacter={jumpToCharacter}
isSmallScreen={isSmallScreen}
/> />
)}
<MovieIndexFooter /> <PageToolbarSeparator />
</div>
) : null}
{!error && isPopulated && !items.length ? ( <MovieIndexViewMenu
<NoMovie totalItems={totalItems} /> view={view}
) : null} isDisabled={hasNoMovie}
</PageContentBody> onViewSelect={onViewSelect}
/>
{isLoaded && !!jumpBarItems.order.length ? ( <MovieIndexSortMenu
<PageJumpBar items={jumpBarItems} onItemPress={onJumpBarItemPress} /> sortKey={sortKey}
sortDirection={sortDirection}
isDisabled={hasNoMovie}
onSortSelect={onSortSelect}
/>
<MovieIndexFilterMenu
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
isDisabled={hasNoMovie}
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>Unable to load movie</div> : null}
{isLoaded ? (
<div className={styles.contentBodyContainer}>
<ViewComponent
scrollerRef={scrollerRef}
items={items}
sortKey={sortKey}
sortDirection={sortDirection}
jumpToCharacter={jumpToCharacter}
isSelectMode={isSelectMode}
isSmallScreen={isSmallScreen}
/>
<MovieIndexFooter />
</div>
) : null}
{!error && isPopulated && !items.length ? (
<NoMovie totalItems={totalItems} />
) : null}
</PageContentBody>
{isLoaded && !!jumpBarItems.order.length ? (
<PageJumpBar
items={jumpBarItems}
onItemPress={onJumpBarItemPress}
/>
) : null}
</div>
{view === 'posters' ? (
<MovieIndexPosterOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
/>
) : null}
{view === 'overview' ? (
<MovieIndexOverviewOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
/>
) : null} ) : null}
</div> </PageContent>
{view === 'posters' ? ( </SelectProvider>
<MovieIndexPosterOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
/>
) : null}
{view === 'overview' ? (
<MovieIndexOverviewOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
/>
) : null}
</PageContent>
); );
}, 'movieIndex'); }, 'movieIndex');

@ -12,6 +12,7 @@ import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal';
import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks'; import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks';
import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector'; import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector';
import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar'; import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar';
import MovieIndexPosterSelect from 'Movie/Index/Select/MovieIndexPosterSelect';
import MoviePoster from 'Movie/MoviePoster'; import MoviePoster from 'Movie/MoviePoster';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import dimensions from 'Styles/Variables/dimensions'; import dimensions from 'Styles/Variables/dimensions';
@ -39,6 +40,7 @@ interface MovieIndexOverviewProps {
posterWidth: number; posterWidth: number;
posterHeight: number; posterHeight: number;
rowHeight: number; rowHeight: number;
isSelectMode: boolean;
isSmallScreen: boolean; isSmallScreen: boolean;
} }
@ -49,6 +51,7 @@ function MovieIndexOverview(props: MovieIndexOverviewProps) {
posterWidth, posterWidth,
posterHeight, posterHeight,
rowHeight, rowHeight,
isSelectMode,
isSmallScreen, isSmallScreen,
} = props; } = props;
@ -132,6 +135,7 @@ function MovieIndexOverview(props: MovieIndexOverviewProps) {
<div className={styles.content}> <div className={styles.content}>
<div className={styles.poster}> <div className={styles.poster}>
<div className={styles.posterContainer}> <div className={styles.posterContainer}>
{isSelectMode ? <MovieIndexPosterSelect movieId={movieId} /> : null}
<Link className={styles.link} style={elementStyle} to={link}> <Link className={styles.link} style={elementStyle} to={link}>
<MoviePoster <MoviePoster
className={styles.poster} className={styles.poster}

@ -27,6 +27,7 @@ interface RowItemData {
posterWidth: number; posterWidth: number;
posterHeight: number; posterHeight: number;
rowHeight: number; rowHeight: number;
isSelectMode: boolean;
isSmallScreen: boolean; isSmallScreen: boolean;
} }
@ -37,6 +38,7 @@ interface MovieIndexOverviewsProps {
jumpToCharacter?: string; jumpToCharacter?: string;
scrollTop?: number; scrollTop?: number;
scrollerRef: React.MutableRefObject<HTMLElement>; scrollerRef: React.MutableRefObject<HTMLElement>;
isSelectMode: boolean;
isSmallScreen: boolean; isSmallScreen: boolean;
} }
@ -65,7 +67,14 @@ function getWindowScrollTopPosition() {
} }
function MovieIndexOverviews(props: MovieIndexOverviewsProps) { function MovieIndexOverviews(props: MovieIndexOverviewsProps) {
const { items, sortKey, jumpToCharacter, isSmallScreen, scrollerRef } = props; const {
items,
sortKey,
jumpToCharacter,
scrollerRef,
isSelectMode,
isSmallScreen,
} = props;
const { size: posterSize, detailedProgressBar } = useSelector( const { size: posterSize, detailedProgressBar } = useSelector(
selectOverviewOptions selectOverviewOptions
@ -191,6 +200,7 @@ function MovieIndexOverviews(props: MovieIndexOverviewsProps) {
posterWidth, posterWidth,
posterHeight, posterHeight,
rowHeight, rowHeight,
isSelectMode,
isSmallScreen, isSmallScreen,
}} }}
> >

@ -12,6 +12,7 @@ import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal';
import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks'; import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks';
import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector'; import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector';
import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar'; import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar';
import MovieIndexPosterSelect from 'Movie/Index/Select/MovieIndexPosterSelect';
import MoviePoster from 'Movie/MoviePoster'; import MoviePoster from 'Movie/MoviePoster';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
@ -24,12 +25,13 @@ import styles from './MovieIndexPoster.css';
interface MovieIndexPosterProps { interface MovieIndexPosterProps {
movieId: number; movieId: number;
sortKey: string; sortKey: string;
isSelectMode: boolean;
posterWidth: number; posterWidth: number;
posterHeight: number; posterHeight: number;
} }
function MovieIndexPoster(props: MovieIndexPosterProps) { function MovieIndexPoster(props: MovieIndexPosterProps) {
const { movieId, sortKey, posterWidth, posterHeight } = props; const { movieId, sortKey, isSelectMode, posterWidth, posterHeight } = props;
const { movie, qualityProfile, isRefreshingMovie, isSearchingMovie } = const { movie, qualityProfile, isRefreshingMovie, isSearchingMovie } =
useSelector(createMovieIndexItemSelector(props.movieId)); useSelector(createMovieIndexItemSelector(props.movieId));
@ -124,6 +126,8 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
return ( return (
<div className={styles.content}> <div className={styles.content}>
<div className={styles.posterContainer}> <div className={styles.posterContainer}>
{isSelectMode ? <MovieIndexPosterSelect movieId={movieId} /> : null}
<Label className={styles.controls}> <Label className={styles.controls}>
<SpinnerIconButton <SpinnerIconButton
name={icons.REFRESH} name={icons.REFRESH}

@ -36,6 +36,7 @@ interface CellItemData {
}; };
items: Movie[]; items: Movie[];
sortKey: string; sortKey: string;
isSelectMode: boolean;
} }
interface MovieIndexPostersProps { interface MovieIndexPostersProps {
@ -45,6 +46,7 @@ interface MovieIndexPostersProps {
jumpToCharacter?: string; jumpToCharacter?: string;
scrollTop?: number; scrollTop?: number;
scrollerRef: React.MutableRefObject<HTMLElement>; scrollerRef: React.MutableRefObject<HTMLElement>;
isSelectMode: boolean;
isSmallScreen: boolean; isSmallScreen: boolean;
} }
@ -63,7 +65,7 @@ const Cell: React.FC<GridChildComponentProps<CellItemData>> = ({
style, style,
data, data,
}) => { }) => {
const { layout, items, sortKey } = data; const { layout, items, sortKey, isSelectMode } = data;
const { columnCount, padding, posterWidth, posterHeight } = layout; const { columnCount, padding, posterWidth, posterHeight } = layout;
@ -85,6 +87,7 @@ const Cell: React.FC<GridChildComponentProps<CellItemData>> = ({
<MovieIndexPoster <MovieIndexPoster
movieId={movie.id} movieId={movie.id}
sortKey={sortKey} sortKey={sortKey}
isSelectMode={isSelectMode}
posterWidth={posterWidth} posterWidth={posterWidth}
posterHeight={posterHeight} posterHeight={posterHeight}
/> />
@ -97,7 +100,14 @@ function getWindowScrollTopPosition() {
} }
export default function MovieIndexPosters(props: MovieIndexPostersProps) { export default function MovieIndexPosters(props: MovieIndexPostersProps) {
const { scrollerRef, items, sortKey, jumpToCharacter, isSmallScreen } = props; const {
scrollerRef,
items,
sortKey,
jumpToCharacter,
isSelectMode,
isSmallScreen,
} = props;
const { posterOptions } = useSelector(movieIndexSelector); const { posterOptions } = useSelector(movieIndexSelector);
const ref: React.MutableRefObject<Grid> = useRef(); const ref: React.MutableRefObject<Grid> = useRef();
@ -276,6 +286,7 @@ export default function MovieIndexPosters(props: MovieIndexPostersProps) {
}, },
items, items,
sortKey, sortKey,
isSelectMode,
}} }}
> >
{Cell} {Cell}

@ -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(--sonarrBlue);
&:hover {
color: var(--white);
}
}
.unselected {
composes: icon;
color: var(--white);
&:hover {
color: var(--sonarrBlue);
}
}

@ -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;

@ -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 './MovieIndexPosterSelect.css';
interface MovieIndexPosterSelectProps {
movieId: number;
}
function MovieIndexPosterSelect(props: MovieIndexPosterSelectProps) {
const { movieId } = props;
const [selectState, selectDispatch] = useSelect();
const isSelected = selectState.selectedState[movieId];
const onSelectPress = useCallback(
(event) => {
const shiftKey = event.nativeEvent.shiftKey;
selectDispatch({
type: SelectActionType.ToggleSelected,
id: movieId,
isSelected: !isSelected,
shiftKey,
});
},
[movieId, 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 MovieIndexPosterSelect;

@ -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 MovieIndexSelectAllButton() {
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 MovieIndexSelectAllButton;

@ -8,6 +8,7 @@ import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import RottenTomatoRating from 'Components/RottenTomatoRating'; import RottenTomatoRating from 'Components/RottenTomatoRating';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
import Column from 'Components/Table/Column'; import Column from 'Components/Table/Column';
import TagListConnector from 'Components/TagListConnector'; import TagListConnector from 'Components/TagListConnector';
import TmdbRating from 'Components/TmdbRating'; import TmdbRating from 'Components/TmdbRating';
@ -27,15 +28,17 @@ import translate from 'Utilities/String/translate';
import MovieStatusCell from './MovieStatusCell'; import MovieStatusCell from './MovieStatusCell';
import selectTableOptions from './selectTableOptions'; import selectTableOptions from './selectTableOptions';
import styles from './MovieIndexRow.css'; import styles from './MovieIndexRow.css';
import { SelectActionType, useSelect } from 'App/SelectContext';
interface MovieIndexRowProps { interface MovieIndexRowProps {
movieId: number; movieId: number;
sortKey: string; sortKey: string;
columns: Column[]; columns: Column[];
isSelectMode: boolean;
} }
function MovieIndexRow(props: MovieIndexRowProps) { function MovieIndexRow(props: MovieIndexRowProps) {
const { movieId, columns } = props; const { movieId, columns, isSelectMode } = props;
const { movie, qualityProfile, isRefreshingMovie, isSearchingMovie } = const { movie, qualityProfile, isRefreshingMovie, isSearchingMovie } =
useSelector(createMovieIndexItemSelector(props.movieId)); useSelector(createMovieIndexItemSelector(props.movieId));
@ -75,6 +78,7 @@ function MovieIndexRow(props: MovieIndexRowProps) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [isEditMovieModalOpen, setIsEditMovieModalOpen] = useState(false); const [isEditMovieModalOpen, setIsEditMovieModalOpen] = useState(false);
const [isDeleteMovieModalOpen, setIsDeleteMovieModalOpen] = useState(false); const [isDeleteMovieModalOpen, setIsDeleteMovieModalOpen] = useState(false);
const [selectState, selectDispatch] = useSelect();
const onRefreshPress = useCallback(() => { const onRefreshPress = useCallback(() => {
dispatch( dispatch(
@ -111,8 +115,29 @@ function MovieIndexRow(props: MovieIndexRowProps) {
setIsDeleteMovieModalOpen(false); setIsDeleteMovieModalOpen(false);
}, [setIsDeleteMovieModalOpen]); }, [setIsDeleteMovieModalOpen]);
const onSelectedChange = useCallback(
({ id, value, shiftKey }) => {
selectDispatch({
type: SelectActionType.ToggleSelected,
id,
isSelected: value,
shiftKey,
});
},
[selectDispatch]
);
return ( return (
<> <>
{isSelectMode ? (
<VirtualTableSelectCell
id={movieId}
isSelected={selectState.selectedState[movieId]}
isDisabled={false}
onSelectedChange={onSelectedChange}
/>
) : null}
{columns.map((column) => { {columns.map((column) => {
const { name, isVisible } = column; const { name, isVisible } = column;

@ -25,6 +25,7 @@ interface RowItemData {
items: Movie[]; items: Movie[];
sortKey: string; sortKey: string;
columns: Column[]; columns: Column[];
isSelectMode: boolean;
} }
interface MovieIndexTableProps { interface MovieIndexTableProps {
@ -34,6 +35,7 @@ interface MovieIndexTableProps {
jumpToCharacter?: string; jumpToCharacter?: string;
scrollTop?: number; scrollTop?: number;
scrollerRef: React.MutableRefObject<HTMLElement>; scrollerRef: React.MutableRefObject<HTMLElement>;
isSelectMode: boolean;
isSmallScreen: boolean; isSmallScreen: boolean;
} }
@ -47,7 +49,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
style, style,
data, data,
}) => { }) => {
const { items, sortKey, columns } = data; const { items, sortKey, columns, isSelectMode } = data;
if (index >= items.length) { if (index >= items.length) {
return null; return null;
@ -63,7 +65,12 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
...style, ...style,
}} }}
> >
<MovieIndexRow movieId={movie.id} sortKey={sortKey} columns={columns} /> <MovieIndexRow
movieId={movie.id}
sortKey={sortKey}
columns={columns}
isSelectMode={isSelectMode}
/>
</div> </div>
); );
}; };
@ -78,6 +85,7 @@ function MovieIndexTable(props: MovieIndexTableProps) {
sortKey, sortKey,
sortDirection, sortDirection,
jumpToCharacter, jumpToCharacter,
isSelectMode,
isSmallScreen, isSmallScreen,
scrollerRef, scrollerRef,
} = props; } = props;
@ -172,6 +180,7 @@ function MovieIndexTable(props: MovieIndexTableProps) {
columns={columns} columns={columns}
sortKey={sortKey} sortKey={sortKey}
sortDirection={sortDirection} sortDirection={sortDirection}
isSelectMode={isSelectMode}
/> />
<List<RowItemData> <List<RowItemData>
ref={listRef} ref={listRef}
@ -188,6 +197,7 @@ function MovieIndexTable(props: MovieIndexTableProps) {
items, items,
sortKey, sortKey,
columns, columns,
isSelectMode,
}} }}
> >
{Row} {Row}

@ -1,11 +1,13 @@
import classNames from 'classnames'; import classNames from 'classnames';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { SelectActionType, useSelect } from 'App/SelectContext';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import Column from 'Components/Table/Column'; import Column from 'Components/Table/Column';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection'; import SortDirection from 'Helpers/Props/SortDirection';
import { import {
@ -19,11 +21,13 @@ interface MovieIndexTableHeaderProps {
columns: Column[]; columns: Column[];
sortKey?: string; sortKey?: string;
sortDirection?: SortDirection; sortDirection?: SortDirection;
isSelectMode: boolean;
} }
function MovieIndexTableHeader(props: MovieIndexTableHeaderProps) { function MovieIndexTableHeader(props: MovieIndexTableHeaderProps) {
const { columns, sortKey, sortDirection, isSelectMode } = props; const { columns, sortKey, sortDirection, isSelectMode } = props;
const dispatch = useDispatch(); const dispatch = useDispatch();
const [selectState, selectDispatch] = useSelect();
const onSortPress = useCallback( const onSortPress = useCallback(
(value) => { (value) => {
@ -39,8 +43,25 @@ function MovieIndexTableHeader(props: MovieIndexTableHeaderProps) {
[dispatch] [dispatch]
); );
const onSelectAllChange = useCallback(
({ value }) => {
selectDispatch({
type: value ? SelectActionType.SelectAll : SelectActionType.UnselectAll,
});
},
[selectDispatch]
);
return ( return (
<VirtualTableHeader> <VirtualTableHeader>
{isSelectMode ? (
<VirtualTableSelectAllHeaderCell
allSelected={selectState.allSelected}
allUnselected={selectState.allUnselected}
onSelectAllChange={onSelectAllChange}
/>
) : null}
{columns.map((column) => { {columns.map((column) => {
const { name, label, isSortable, isVisible } = column; const { name, label, isSortable, isVisible } = column;

@ -1,29 +1,29 @@
import areAllSelected from './areAllSelected'; import areAllSelected from './areAllSelected';
import getToggledRange from './getToggledRange'; import getToggledRange from './getToggledRange';
function toggleSelected(state, items, id, selected, shiftKey, idProp = 'id') { function toggleSelected(selectedState, items, id, selected, shiftKey) {
const lastToggled = state.lastToggled; const lastToggled = selectedState.lastToggled;
const selectedState = { const nextSelectedState = {
...state.selectedState, ...selectedState.selectedState,
[id]: selected [id]: selected
}; };
if (selected == null) { if (selected == null) {
delete selectedState[id]; delete nextSelectedState[id];
} }
if (shiftKey && lastToggled) { if (shiftKey && lastToggled) {
const { lower, upper } = getToggledRange(items, id, lastToggled); const { lower, upper } = getToggledRange(items, id, lastToggled);
for (let i = lower; i < upper; i++) { for (let i = lower; i < upper; i++) {
selectedState[items[i][idProp]] = selected; nextSelectedState[items[i].id] = selected;
} }
} }
return { return {
...areAllSelected(selectedState), ...areAllSelected(nextSelectedState),
lastToggled: id, lastToggled: id,
selectedState selectedState: nextSelectedState
}; };
} }

@ -1081,6 +1081,7 @@
"UnableToLoadLanguages": "Unable to load languages", "UnableToLoadLanguages": "Unable to load languages",
"UnableToLoadListExclusions": "Unable to load List Exclusions", "UnableToLoadListExclusions": "Unable to load List Exclusions",
"UnableToLoadListOptions": "Unable to load list options", "UnableToLoadListOptions": "Unable to load list options",
"StopSelecting": "Stop Selecting",
"UnableToLoadLists": "Unable to load Lists", "UnableToLoadLists": "Unable to load Lists",
"UnableToLoadManualImportItems": "Unable to load manual import items", "UnableToLoadManualImportItems": "Unable to load manual import items",
"UnableToLoadMediaManagementSettings": "Unable to load Media Management settings", "UnableToLoadMediaManagementSettings": "Unable to load Media Management settings",

Loading…
Cancel
Save