mirror of
https://github.com/Sonarr/Sonarr.git
synced 2024-11-23 11:13:34 +01:00
Added series index selection
This commit is contained in:
parent
5aad84dba4
commit
815a16d5cf
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;
|
||||
}
|
@ -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
|
||||
@ -83,6 +84,8 @@ import {
|
||||
faSortDown as fasSortDown,
|
||||
faSortUp as fasSortUp,
|
||||
faSpinner as fasSpinner,
|
||||
faSquareCheck as fasSquareCheck,
|
||||
faSquareMinus as fasSquareMinus,
|
||||
faStop as fasStop,
|
||||
faSync as fasSync,
|
||||
faTable as fasTable,
|
||||
@ -116,6 +119,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;
|
||||
@ -192,6 +196,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 SUBTRACT = fasMinus;
|
||||
export const SYSTEM = fasLaptop;
|
||||
export const TABLE = fasTable;
|
||||
|
@ -9,6 +9,7 @@ import { icons } from 'Helpers/Props';
|
||||
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
|
||||
import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
|
||||
import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar';
|
||||
import SeriesIndexPosterSelect from 'Series/Index/Select/SeriesIndexPosterSelect';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import dimensions from 'Styles/Variables/dimensions';
|
||||
@ -35,6 +36,7 @@ interface SeriesIndexOverviewProps {
|
||||
posterWidth: number;
|
||||
posterHeight: number;
|
||||
rowHeight: number;
|
||||
isSelectMode: boolean;
|
||||
isSmallScreen: boolean;
|
||||
}
|
||||
|
||||
@ -45,6 +47,7 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) {
|
||||
posterWidth,
|
||||
posterHeight,
|
||||
rowHeight,
|
||||
isSelectMode,
|
||||
isSmallScreen,
|
||||
} = props;
|
||||
|
||||
@ -135,6 +138,10 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) {
|
||||
<div className={styles.content}>
|
||||
<div className={styles.poster}>
|
||||
<div className={styles.posterContainer}>
|
||||
{isSelectMode ? (
|
||||
<SeriesIndexPosterSelect seriesId={seriesId} />
|
||||
) : null}
|
||||
|
||||
{status === 'ended' && (
|
||||
<div className={styles.ended} title="Ended" />
|
||||
)}
|
||||
|
@ -27,6 +27,7 @@ interface RowItemData {
|
||||
posterWidth: number;
|
||||
posterHeight: number;
|
||||
rowHeight: number;
|
||||
isSelectMode: boolean;
|
||||
isSmallScreen: boolean;
|
||||
}
|
||||
|
||||
@ -37,6 +38,7 @@ interface SeriesIndexOverviewsProps {
|
||||
jumpToCharacter?: string;
|
||||
scrollTop?: number;
|
||||
scrollerRef: React.MutableRefObject<HTMLElement>;
|
||||
isSelectMode: boolean;
|
||||
isSmallScreen: boolean;
|
||||
}
|
||||
|
||||
@ -65,7 +67,14 @@ function getWindowScrollTopPosition() {
|
||||
}
|
||||
|
||||
function SeriesIndexOverviews(props: SeriesIndexOverviewsProps) {
|
||||
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 SeriesIndexOverviews(props: SeriesIndexOverviewsProps) {
|
||||
posterWidth,
|
||||
posterHeight,
|
||||
rowHeight,
|
||||
isSelectMode,
|
||||
isSmallScreen,
|
||||
}}
|
||||
>
|
||||
|
@ -9,6 +9,7 @@ import { icons } from 'Helpers/Props';
|
||||
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
|
||||
import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
|
||||
import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar';
|
||||
import SeriesIndexPosterSelect from 'Series/Index/Select/SeriesIndexPosterSelect';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
@ -21,12 +22,13 @@ import styles from './SeriesIndexPoster.css';
|
||||
interface SeriesIndexPosterProps {
|
||||
seriesId: number;
|
||||
sortKey: string;
|
||||
isSelectMode: boolean;
|
||||
posterWidth: number;
|
||||
posterHeight: number;
|
||||
}
|
||||
|
||||
function SeriesIndexPoster(props: SeriesIndexPosterProps) {
|
||||
const { seriesId, sortKey, posterWidth, posterHeight } = props;
|
||||
const { seriesId, sortKey, isSelectMode, posterWidth, posterHeight } = props;
|
||||
|
||||
const { series, qualityProfile, isRefreshingSeries, isSearchingSeries } =
|
||||
useSelector(createSeriesIndexItemSelector(props.seriesId));
|
||||
@ -120,6 +122,8 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.posterContainer}>
|
||||
{isSelectMode ? <SeriesIndexPosterSelect seriesId={seriesId} /> : null}
|
||||
|
||||
<Label className={styles.controls}>
|
||||
<SpinnerIconButton
|
||||
className={styles.action}
|
||||
|
@ -36,6 +36,7 @@ interface CellItemData {
|
||||
};
|
||||
items: Series[];
|
||||
sortKey: string;
|
||||
isSelectMode: boolean;
|
||||
}
|
||||
|
||||
interface SeriesIndexPostersProps {
|
||||
@ -45,6 +46,7 @@ interface SeriesIndexPostersProps {
|
||||
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>> = ({
|
||||
<SeriesIndexPoster
|
||||
seriesId={series.id}
|
||||
sortKey={sortKey}
|
||||
isSelectMode={isSelectMode}
|
||||
posterWidth={posterWidth}
|
||||
posterHeight={posterHeight}
|
||||
/>
|
||||
@ -97,7 +98,14 @@ function getWindowScrollTopPosition() {
|
||||
}
|
||||
|
||||
export default function SeriesIndexPosters(props: SeriesIndexPostersProps) {
|
||||
const { scrollerRef, items, sortKey, jumpToCharacter, isSmallScreen } = props;
|
||||
const {
|
||||
scrollerRef,
|
||||
items,
|
||||
sortKey,
|
||||
jumpToCharacter,
|
||||
isSelectMode,
|
||||
isSmallScreen,
|
||||
} = props;
|
||||
|
||||
const { posterOptions } = useSelector(seriesIndexSelector);
|
||||
const ref: React.MutableRefObject<Grid> = useRef();
|
||||
@ -273,6 +281,7 @@ export default function SeriesIndexPosters(props: SeriesIndexPostersProps) {
|
||||
},
|
||||
items,
|
||||
sortKey,
|
||||
isSelectMode,
|
||||
}}
|
||||
>
|
||||
{Cell}
|
||||
|
36
frontend/src/Series/Index/Select/SeriesIndexPosterSelect.css
Normal file
36
frontend/src/Series/Index/Select/SeriesIndexPosterSelect.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(--sonarrBlue);
|
||||
|
||||
&:hover {
|
||||
color: var(--white);
|
||||
}
|
||||
}
|
||||
|
||||
.unselected {
|
||||
composes: icon;
|
||||
|
||||
color: var(--white);
|
||||
|
||||
&:hover {
|
||||
color: var(--sonarrBlue);
|
||||
}
|
||||
}
|
41
frontend/src/Series/Index/Select/SeriesIndexPosterSelect.tsx
Normal file
41
frontend/src/Series/Index/Select/SeriesIndexPosterSelect.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 './SeriesIndexPosterSelect.css';
|
||||
|
||||
interface SeriesIndexPosterSelectProps {
|
||||
seriesId: number;
|
||||
}
|
||||
|
||||
function SeriesIndexPosterSelect(props: SeriesIndexPosterSelectProps) {
|
||||
const { seriesId } = props;
|
||||
const [selectState, selectDispatch] = useSelect();
|
||||
const isSelected = selectState.selectedState[seriesId];
|
||||
|
||||
const onSelectPress = useCallback(
|
||||
(event) => {
|
||||
const shiftKey = event.nativeEvent.shiftKey;
|
||||
|
||||
selectDispatch({
|
||||
type: SelectActionType.ToggleSelected,
|
||||
id: seriesId,
|
||||
isSelected: !isSelected,
|
||||
shiftKey,
|
||||
});
|
||||
},
|
||||
[seriesId, 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 SeriesIndexPosterSelect;
|
@ -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 SeriesIndexSelectAllButton() {
|
||||
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 SeriesIndexSelectAllButton;
|
@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { SelectProvider } from 'App/SelectContext';
|
||||
import { REFRESH_SERIES, RSS_SYNC } from 'Commands/commandNames';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
@ -32,6 +33,7 @@ import SeriesIndexOverviewOptionsModal from './Overview/Options/SeriesIndexOverv
|
||||
import SeriesIndexOverviews from './Overview/SeriesIndexOverviews';
|
||||
import SeriesIndexPosterOptionsModal from './Posters/Options/SeriesIndexPosterOptionsModal';
|
||||
import SeriesIndexPosters from './Posters/SeriesIndexPosters';
|
||||
import SeriesIndexSelectAllButton from './Select/SeriesIndexSelectAllButton';
|
||||
import SeriesIndexFooter from './SeriesIndexFooter';
|
||||
import SeriesIndexTable from './Table/SeriesIndexTable';
|
||||
import SeriesIndexTableOptions from './Table/SeriesIndexTableOptions';
|
||||
@ -53,7 +55,7 @@ interface SeriesIndexProps {
|
||||
initialScrollTop?: number;
|
||||
}
|
||||
|
||||
const SeriesIndex = withScrollPosition((props) => {
|
||||
const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
@ -80,6 +82,7 @@ const SeriesIndex = withScrollPosition((props) => {
|
||||
const scrollerRef = useRef<HTMLDivElement>();
|
||||
const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false);
|
||||
const [jumpToCharacter, setJumpToCharacter] = useState<string | null>(null);
|
||||
const [isSelectMode, setIsSelectMode] = useState(false);
|
||||
|
||||
const onRefreshSeriesPress = useCallback(() => {
|
||||
dispatch(
|
||||
@ -97,6 +100,10 @@ const SeriesIndex = withScrollPosition((props) => {
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
const onSelectModePress = useCallback(() => {
|
||||
setIsSelectMode(!isSelectMode);
|
||||
}, [isSelectMode, setIsSelectMode]);
|
||||
|
||||
const onTableOptionChange = useCallback(
|
||||
(payload) => {
|
||||
dispatch(setSeriesTableOption(payload));
|
||||
@ -194,118 +201,137 @@ const SeriesIndex = withScrollPosition((props) => {
|
||||
const hasNoSeries = !totalItems;
|
||||
|
||||
return (
|
||||
<PageContent>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label="Update all"
|
||||
iconName={icons.REFRESH}
|
||||
spinningName={icons.REFRESH}
|
||||
isSpinning={isRefreshingSeries}
|
||||
isDisabled={hasNoSeries}
|
||||
onPress={onRefreshSeriesPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label="RSS Sync"
|
||||
iconName={icons.RSS}
|
||||
isSpinning={isRssSyncExecuting}
|
||||
isDisabled={hasNoSeries}
|
||||
onPress={onRssSyncPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT} collapseButtons={false}>
|
||||
{view === 'table' ? (
|
||||
<TableOptionsModalWrapper
|
||||
columns={columns}
|
||||
optionsComponent={SeriesIndexTableOptions}
|
||||
onTableOptionChange={onTableOptionChange}
|
||||
>
|
||||
<PageToolbarButton label="Options" iconName={icons.TABLE} />
|
||||
</TableOptionsModalWrapper>
|
||||
) : (
|
||||
<SelectProvider isSelectMode={isSelectMode} items={items}>
|
||||
<PageContent>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label="Options"
|
||||
iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW}
|
||||
label="Update all"
|
||||
iconName={icons.REFRESH}
|
||||
spinningName={icons.REFRESH}
|
||||
isSpinning={isRefreshingSeries}
|
||||
isDisabled={hasNoSeries}
|
||||
onPress={onOptionsPress}
|
||||
onPress={onRefreshSeriesPress}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PageToolbarSeparator />
|
||||
<PageToolbarButton
|
||||
label="RSS Sync"
|
||||
iconName={icons.RSS}
|
||||
isSpinning={isRssSyncExecuting}
|
||||
isDisabled={hasNoSeries}
|
||||
onPress={onRssSyncPress}
|
||||
/>
|
||||
|
||||
<SeriesIndexViewMenu
|
||||
view={view}
|
||||
isDisabled={hasNoSeries}
|
||||
onViewSelect={onViewSelect}
|
||||
/>
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<SeriesIndexSortMenu
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
isDisabled={hasNoSeries}
|
||||
onSortSelect={onSortSelect}
|
||||
/>
|
||||
<PageToolbarButton
|
||||
label={isSelectMode ? 'Stop Selecting' : 'Select Series'}
|
||||
iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK}
|
||||
onPress={onSelectModePress}
|
||||
/>
|
||||
|
||||
<SeriesIndexFilterMenu
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={customFilters}
|
||||
isDisabled={hasNoSeries}
|
||||
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}
|
||||
{isSelectMode ? <SeriesIndexSelectAllButton /> : null}
|
||||
</PageToolbarSection>
|
||||
|
||||
{!isFetching && !!error ? <div>Unable to load series</div> : null}
|
||||
|
||||
{isLoaded ? (
|
||||
<div className={styles.contentBodyContainer}>
|
||||
<ViewComponent
|
||||
scrollerRef={scrollerRef}
|
||||
items={items}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
jumpToCharacter={jumpToCharacter}
|
||||
isSmallScreen={isSmallScreen}
|
||||
<PageToolbarSection
|
||||
alignContent={align.RIGHT}
|
||||
collapseButtons={false}
|
||||
>
|
||||
{view === 'table' ? (
|
||||
<TableOptionsModalWrapper
|
||||
columns={columns}
|
||||
optionsComponent={SeriesIndexTableOptions}
|
||||
onTableOptionChange={onTableOptionChange}
|
||||
>
|
||||
<PageToolbarButton label="Options" iconName={icons.TABLE} />
|
||||
</TableOptionsModalWrapper>
|
||||
) : (
|
||||
<PageToolbarButton
|
||||
label="Options"
|
||||
iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW}
|
||||
isDisabled={hasNoSeries}
|
||||
onPress={onOptionsPress}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SeriesIndexFooter />
|
||||
</div>
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<SeriesIndexViewMenu
|
||||
view={view}
|
||||
isDisabled={hasNoSeries}
|
||||
onViewSelect={onViewSelect}
|
||||
/>
|
||||
|
||||
<SeriesIndexSortMenu
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
isDisabled={hasNoSeries}
|
||||
onSortSelect={onSortSelect}
|
||||
/>
|
||||
|
||||
<SeriesIndexFilterMenu
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={customFilters}
|
||||
isDisabled={hasNoSeries}
|
||||
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 series</div> : null}
|
||||
|
||||
{isLoaded ? (
|
||||
<div className={styles.contentBodyContainer}>
|
||||
<ViewComponent
|
||||
scrollerRef={scrollerRef}
|
||||
items={items}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
jumpToCharacter={jumpToCharacter}
|
||||
isSelectMode={isSelectMode}
|
||||
isSmallScreen={isSmallScreen}
|
||||
/>
|
||||
|
||||
<SeriesIndexFooter />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!error && isPopulated && !items.length ? (
|
||||
<NoSeries totalItems={totalItems} />
|
||||
) : null}
|
||||
</PageContentBody>
|
||||
|
||||
{isLoaded && !!jumpBarItems.order.length ? (
|
||||
<PageJumpBar
|
||||
items={jumpBarItems}
|
||||
onItemPress={onJumpBarItemPress}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!error && isPopulated && !items.length ? (
|
||||
<NoSeries totalItems={totalItems} />
|
||||
) : null}
|
||||
</PageContentBody>
|
||||
|
||||
{isLoaded && !!jumpBarItems.order.length ? (
|
||||
<PageJumpBar items={jumpBarItems} onItemPress={onJumpBarItemPress} />
|
||||
</div>
|
||||
{view === 'posters' ? (
|
||||
<SeriesIndexPosterOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={onOptionsModalClose}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{view === 'posters' ? (
|
||||
<SeriesIndexPosterOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={onOptionsModalClose}
|
||||
/>
|
||||
) : null}
|
||||
{view === 'overview' ? (
|
||||
<SeriesIndexOverviewOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={onOptionsModalClose}
|
||||
/>
|
||||
) : null}
|
||||
</PageContent>
|
||||
{view === 'overview' ? (
|
||||
<SeriesIndexOverviewOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={onOptionsModalClose}
|
||||
/>
|
||||
) : null}
|
||||
</PageContent>
|
||||
</SelectProvider>
|
||||
);
|
||||
}, 'seriesIndex');
|
||||
|
||||
|
@ -11,6 +11,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 +33,11 @@ interface SeriesIndexRowProps {
|
||||
seriesId: number;
|
||||
sortKey: string;
|
||||
columns: Column[];
|
||||
isSelectMode: boolean;
|
||||
}
|
||||
|
||||
function SeriesIndexRow(props: SeriesIndexRowProps) {
|
||||
const { seriesId, columns } = props;
|
||||
const { seriesId, columns, isSelectMode } = props;
|
||||
|
||||
const {
|
||||
series,
|
||||
@ -82,6 +84,7 @@ function SeriesIndexRow(props: SeriesIndexRowProps) {
|
||||
const [hasBannerError, setHasBannerError] = useState(false);
|
||||
const [isEditSeriesModalOpen, setIsEditSeriesModalOpen] = useState(false);
|
||||
const [isDeleteSeriesModalOpen, setIsDeleteSeriesModalOpen] = useState(false);
|
||||
const [selectState, selectDispatch] = useSelect();
|
||||
|
||||
const onRefreshPress = useCallback(() => {
|
||||
dispatch(
|
||||
@ -130,8 +133,29 @@ function SeriesIndexRow(props: SeriesIndexRowProps) {
|
||||
// Mock handler to satisfy `onChange` being required for `CheckInput`.
|
||||
}, []);
|
||||
|
||||
const onSelectedChange = useCallback(
|
||||
({ id, value, shiftKey }) => {
|
||||
selectDispatch({
|
||||
type: SelectActionType.ToggleSelected,
|
||||
id,
|
||||
isSelected: value,
|
||||
shiftKey,
|
||||
});
|
||||
},
|
||||
[selectDispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSelectMode ? (
|
||||
<VirtualTableSelectCell
|
||||
id={seriesId}
|
||||
isSelected={selectState.selectedState[seriesId]}
|
||||
isDisabled={false}
|
||||
onSelectedChange={onSelectedChange}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{columns.map((column) => {
|
||||
const { name, isVisible } = column;
|
||||
|
||||
|
@ -25,6 +25,7 @@ interface RowItemData {
|
||||
items: Series[];
|
||||
sortKey: string;
|
||||
columns: Column[];
|
||||
isSelectMode: boolean;
|
||||
}
|
||||
|
||||
interface SeriesIndexTableProps {
|
||||
@ -34,6 +35,7 @@ interface SeriesIndexTableProps {
|
||||
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>> = ({
|
||||
seriesId={series.id}
|
||||
sortKey={sortKey}
|
||||
columns={columns}
|
||||
isSelectMode={isSelectMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -82,6 +85,7 @@ function SeriesIndexTable(props: SeriesIndexTableProps) {
|
||||
sortKey,
|
||||
sortDirection,
|
||||
jumpToCharacter,
|
||||
isSelectMode,
|
||||
isSmallScreen,
|
||||
scrollerRef,
|
||||
} = props;
|
||||
@ -177,6 +181,7 @@ function SeriesIndexTable(props: SeriesIndexTableProps) {
|
||||
columns={columns}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
isSelectMode={isSelectMode}
|
||||
/>
|
||||
<List<RowItemData>
|
||||
ref={listRef}
|
||||
@ -193,6 +198,7 @@ function SeriesIndexTable(props: SeriesIndexTableProps) {
|
||||
items,
|
||||
sortKey,
|
||||
columns,
|
||||
isSelectMode,
|
||||
}}
|
||||
>
|
||||
{Row}
|
||||
|
@ -7,6 +7,7 @@ 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 {
|
||||
@ -22,12 +23,13 @@ interface SeriesIndexTableHeaderProps {
|
||||
columns: Column[];
|
||||
sortKey?: string;
|
||||
sortDirection?: SortDirection;
|
||||
isSelectMode: boolean;
|
||||
}
|
||||
|
||||
function SeriesIndexTableHeader(props: SeriesIndexTableHeaderProps) {
|
||||
const { showBanners, columns, sortKey, sortDirection } = props;
|
||||
|
||||
const { showBanners, columns, sortKey, sortDirection, isSelectMode } = props;
|
||||
const dispatch = useDispatch();
|
||||
const [selectState, selectDispatch] = useSelect();
|
||||
|
||||
const onSortPress = useCallback(
|
||||
(value) => {
|
||||
@ -43,8 +45,25 @@ function SeriesIndexTableHeader(props: SeriesIndexTableHeaderProps) {
|
||||
[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;
|
||||
|
||||
|
@ -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…
Reference in New Issue
Block a user