Feature/library manga actions (#506)

* Extend "IMangaGridProps" from "DefaultGridProps"

* Move library manga filtering into hook

* Add logic to select mangas

* Add manga actions

* Remove cache only policy for category mangas

Unclear why this was added

* Prevent SelectionFAB from being hidden by the mobile footer

On e.g. the library page, the footer is visible and thus, the fab was not completely visible

* Update categories of manga only after clicking "OK"

Previously, selecting a category resulted in an immediate mutation.
To be able to reuse the "CategorySelect" component to change the categories of multiple mangas at once, this behaviour is not suited.

* Add action to change categories of multiple mangas

* Cancel selection mode after removal from library

The mangas would still be selected and other action could get performed. However, this should only be possible for mangas that are in the library

* Remove unintentionally added console log

Accidentally added with 980da657d9
This commit is contained in:
schroda
2023-12-25 21:37:45 +01:00
committed by GitHub
parent 980da657d9
commit 446deeae06
23 changed files with 1760 additions and 427 deletions

View File

@@ -45,8 +45,10 @@
"graphql-ws": "^5.14.2",
"i18next": "^23.7.6",
"i18next-browser-languagedetector": "^7.2.0",
"material-ui-popup-state": "^5.0.10",
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-device-detect": "^2.2.3",
"react-dom": "^18.2.0",
"react-i18next": "^13.5.0",
"react-router-dom": "^6.20.0",

View File

@@ -12,11 +12,16 @@ import Typography from '@mui/material/Typography';
import { Link } from 'react-router-dom';
import { Avatar, Box, CardContent, Stack, styled, Tooltip } from '@mui/material';
import { useTranslation } from 'react-i18next';
import React from 'react';
import PopupState, { bindMenu } from 'material-ui-popup-state';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import { SpinnerImage } from '@/components/util/SpinnerImage';
import { TPartialManga } from '@/typings.ts';
import { TManga, TPartialManga } from '@/typings.ts';
import { ContinueReadingButton } from '@/components/manga/ContinueReadingButton.tsx';
import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts';
import { MangaOptionButton } from '@/components/manga/MangaOptionButton.tsx';
import { MangaActionMenu } from '@/components/manga/MangaActionMenu.tsx';
const BottomGradient = styled('div')({
position: 'absolute',
@@ -65,25 +70,24 @@ interface IProps {
manga: TPartialManga;
gridLayout?: GridLayout;
inLibraryIndicator?: boolean;
selected?: boolean | null;
handleSelection?: SelectableCollectionReturnType<TManga['id']>['handleSelection'];
}
export const MangaCard = (props: IProps) => {
const { t } = useTranslation();
const { manga, gridLayout, inLibraryIndicator, selected, handleSelection } = props;
const {
manga: {
id,
title,
thumbnailUrl: tmpThumbnailUrl,
downloadCount,
unreadCount: unread,
inLibrary,
lastReadChapter,
chapters,
},
gridLayout,
inLibraryIndicator,
} = props;
id,
title,
thumbnailUrl: tmpThumbnailUrl,
downloadCount,
unreadCount: unread,
inLibrary,
lastReadChapter,
chapters,
} = manga;
const thumbnailUrl = tmpThumbnailUrl ?? 'nonExistingMangaUrl';
const {
options: { showContinueReadingButton, showUnreadBadge, showDownloadBadge },
@@ -96,208 +100,294 @@ export const MangaCard = (props: IProps) => {
if (gridLayout !== GridLayout.List) {
return (
<Link to={mangaLinkTo} style={gridLayout === GridLayout.Comfortable ? { textDecoration: 'none' } : {}}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
}}
>
<Card
sx={{
// force standard aspect ratio of manga covers
aspectRatio: '225/350',
display: 'flex',
}}
>
<CardActionArea
sx={{
position: 'relative',
height: '100%',
<PopupState variant="popover" popupId="manga-card-action-menu">
{(popupState) => (
<>
<Link
onClick={(e) => {
if (selected === null) {
return;
}
e.preventDefault();
handleSelection?.(id, !selected);
}}
to={mangaLinkTo}
style={gridLayout === GridLayout.Comfortable ? { textDecoration: 'none' } : {}}
>
<BadgeContainer
<Box
sx={{
position: 'absolute',
top: 5,
left: 5,
display: 'flex',
flexDirection: 'column',
margin: '2px',
outline: selected ? '4px solid' : undefined,
borderRadius: selected ? '1px' : undefined,
outlineColor: (theme) => theme.palette.primary.main,
backgroundColor: (theme) => (selected ? theme.palette.primary.main : undefined),
'@media (hover: hover) and (pointer: fine)': {
'&:hover .manga-option-button': {
visibility: 'visible',
pointerEvents: 'all',
},
},
}}
>
{inLibraryIndicator && inLibrary && (
<Typography sx={{ backgroundColor: 'primary.dark', zIndex: '1' }}>
{t('manga.button.in_library')}
</Typography>
)}
{showUnreadBadge && (unread ?? 0) > 0 && (
<Typography sx={{ backgroundColor: 'primary.dark' }}>{unread}</Typography>
)}
{showDownloadBadge && (downloadCount ?? 0) > 0 && (
<Typography
sx={{
backgroundColor: 'success.dark',
}}
>
{downloadCount}
</Typography>
)}
</BadgeContainer>
<SpinnerImage
alt={title}
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
imgStyle={
inLibraryIndicator && inLibrary
? {
height: '100%',
width: '100%',
objectFit: 'cover',
filter: 'brightness(0.4)',
}
: {
height: '100%',
width: '100%',
objectFit: 'cover',
}
}
spinnerStyle={{
display: 'grid',
placeItems: 'center',
}}
/>
<>
<BottomGradient />
<BottomGradientDoubledDown />
<Stack
direction="row"
justifyContent={gridLayout !== GridLayout.Comfortable ? 'space-between' : 'end'}
alignItems="end"
<Card
sx={{
position: 'absolute',
bottom: 0,
width: '100%',
margin: '0.5em 0',
padding: '0 0.5em',
gap: '0.5em',
// force standard aspect ratio of manga covers
aspectRatio: '225/350',
display: 'flex',
}}
>
{gridLayout !== GridLayout.Comfortable && (
<Tooltip title={title} placement="top">
<GridMangaTitle
<CardActionArea
sx={{
position: 'relative',
height: '100%',
}}
>
<Stack
alignItems="start"
justifyContent="space-between"
direction="row"
sx={{
position: 'absolute',
top: 5,
left: 5,
right: 5,
}}
>
<BadgeContainer>
{inLibraryIndicator && inLibrary && (
<Typography sx={{ backgroundColor: 'primary.dark', zIndex: '1' }}>
{t('manga.button.in_library')}
</Typography>
)}
{showUnreadBadge && (unread ?? 0) > 0 && (
<Typography sx={{ backgroundColor: 'primary.dark' }}>
{unread}
</Typography>
)}
{showDownloadBadge && (downloadCount ?? 0) > 0 && (
<Typography
sx={{
backgroundColor: 'success.dark',
}}
>
{downloadCount}
</Typography>
)}
</BadgeContainer>
<MangaOptionButton
popupState={popupState}
id={id}
selected={selected}
handleSelection={handleSelection}
/>
</Stack>
<SpinnerImage
alt={title}
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
imgStyle={
inLibraryIndicator && inLibrary
? {
height: '100%',
width: '100%',
objectFit: 'cover',
filter: 'brightness(0.4)',
}
: {
height: '100%',
width: '100%',
objectFit: 'cover',
}
}
spinnerStyle={{
display: 'grid',
placeItems: 'center',
}}
/>
<>
{gridLayout !== GridLayout.Comfortable && (
<>
<BottomGradient />
<BottomGradientDoubledDown />
</>
)}
<Stack
direction="row"
justifyContent={
gridLayout !== GridLayout.Comfortable ? 'space-between' : 'end'
}
alignItems="end"
sx={{
color: 'white',
textShadow: '0px 0px 3px #000000',
position: 'absolute',
bottom: 0,
width: '100%',
margin: '0.5em 0',
padding: '0 0.5em',
gap: '0.5em',
}}
>
{title}
</GridMangaTitle>
</Tooltip>
)}
{gridLayout !== GridLayout.Comfortable && (
<Tooltip title={title} placement="top">
<GridMangaTitle
sx={{
color: 'white',
textShadow: '0px 0px 3px #000000',
}}
>
{title}
</GridMangaTitle>
</Tooltip>
)}
<ContinueReadingButton
showContinueReadingButton={showContinueReadingButton}
isLatestChapterRead={isLatestChapterRead}
nextChapterIndexToRead={nextChapterIndexToRead}
mangaLinkTo={mangaLinkTo}
/>
</Stack>
</>
</CardActionArea>
</Card>
{gridLayout === GridLayout.Comfortable && (
<Tooltip title={title} placement="top">
<GridMangaTitle
sx={{
position: 'relative',
width: '100%',
bottom: 0,
margin: '0.5em 0',
padding: '0 0.5em',
color: 'text.primary',
height: '3rem',
}}
>
{title}
</GridMangaTitle>
</Tooltip>
)}
</Box>
</Link>
{!!handleSelection && popupState.isOpen && (
<MangaActionMenu
{...bindMenu(popupState)}
manga={manga as React.ComponentProps<typeof MangaActionMenu>['manga']}
handleSelection={handleSelection}
/>
)}
</>
)}
</PopupState>
);
}
return (
<PopupState variant="popover" popupId="manga-card-action-menu">
{(popupState) => (
<>
<Card>
<CardActionArea
component={Link}
to={mangaLinkTo}
onClick={(e) => {
if (selected === null) {
return;
}
e.preventDefault();
handleSelection?.(id, !selected);
}}
>
<CardContent
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: 2,
position: 'relative',
}}
>
<Avatar
variant="rounded"
sx={
inLibraryIndicator && inLibrary
? {
width: 56,
height: 56,
flex: '0 0 auto',
marginRight: 2,
imageRendering: 'pixelated',
filter: 'brightness(0.4)',
}
: {
width: 56,
height: 56,
flex: '0 0 auto',
marginRight: 2,
imageRendering: 'pixelated',
}
}
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
/>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
flexGrow: 1,
width: 'min-content',
}}
>
<Tooltip title={title} placement="top">
<MangaTitle variant="h5">{title}</MangaTitle>
</Tooltip>
</Box>
<Stack direction="row" alignItems="center" gap="5px">
<BadgeContainer>
{inLibraryIndicator && inLibrary && (
<Typography sx={{ backgroundColor: 'primary.dark' }}>
{t('manga.button.in_library')}
</Typography>
)}
{showUnreadBadge && unread! > 0 && (
<Typography sx={{ backgroundColor: 'primary.dark' }}>{unread}</Typography>
)}
{showDownloadBadge && downloadCount! > 0 && (
<Typography
sx={{
backgroundColor: 'success.dark',
}}
>
{downloadCount}
</Typography>
)}
</BadgeContainer>
<ContinueReadingButton
showContinueReadingButton={showContinueReadingButton}
isLatestChapterRead={isLatestChapterRead}
nextChapterIndexToRead={nextChapterIndexToRead}
mangaLinkTo={mangaLinkTo}
/>
<MangaOptionButton
popupState={popupState}
id={id}
selected={selected}
handleSelection={handleSelection}
asCheckbox
/>
</Stack>
</>
</CardContent>
</CardActionArea>
</Card>
{gridLayout === GridLayout.Comfortable && (
<Tooltip title={title} placement="top">
<GridMangaTitle
sx={{
position: 'relative',
width: '100%',
bottom: 0,
margin: '0.5em 0',
padding: '0 0.5em',
color: 'text.primary',
height: '3rem',
}}
>
{title}
</GridMangaTitle>
</Tooltip>
)}
</Box>
</Link>
);
}
return (
<Card>
<CardActionArea component={Link} to={mangaLinkTo}>
<CardContent
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: 2,
position: 'relative',
}}
>
<Avatar
variant="rounded"
sx={
inLibraryIndicator && inLibrary
? {
width: 56,
height: 56,
flex: '0 0 auto',
marginRight: 2,
imageRendering: 'pixelated',
filter: 'brightness(0.4)',
}
: {
width: 56,
height: 56,
flex: '0 0 auto',
marginRight: 2,
imageRendering: 'pixelated',
}
}
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
/>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
flexGrow: 1,
width: 'min-content',
}}
>
<Tooltip title={title} placement="top">
<MangaTitle variant="h5">{title}</MangaTitle>
</Tooltip>
</Box>
<Stack direction="row" alignItems="center" gap="5px">
<BadgeContainer>
{inLibraryIndicator && inLibrary && (
<Typography sx={{ backgroundColor: 'primary.dark' }}>
{t('manga.button.in_library')}
</Typography>
)}
{showUnreadBadge && unread! > 0 && (
<Typography sx={{ backgroundColor: 'primary.dark' }}>{unread}</Typography>
)}
{showDownloadBadge && downloadCount! > 0 && (
<Typography
sx={{
backgroundColor: 'success.dark',
}}
>
{downloadCount}
</Typography>
)}
</BadgeContainer>
<ContinueReadingButton
showContinueReadingButton={showContinueReadingButton}
isLatestChapterRead={isLatestChapterRead}
nextChapterIndexToRead={nextChapterIndexToRead}
mangaLinkTo={mangaLinkTo}
{!!handleSelection && popupState.isOpen && (
<MangaActionMenu
{...bindMenu(popupState)}
manga={manga as React.ComponentProps<typeof MangaActionMenu>['manga']}
handleSelection={handleSelection}
/>
</Stack>
</CardContent>
</CardActionArea>
</Card>
)}
</>
)}
</PopupState>
);
};

View File

@@ -10,13 +10,15 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 're
import Grid, { GridTypeMap } from '@mui/material/Grid';
import { Box, Typography } from '@mui/material';
import { GridItemProps, GridStateSnapshot, VirtuosoGrid } from 'react-virtuoso';
import { useNavigate, useLocation } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { EmptyView } from '@/components/util/EmptyView';
import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder';
import { MangaCard } from '@/components/MangaCard';
import { GridLayout } from '@/components/context/LibraryOptionsContext';
import { useLocalStorage } from '@/util/useLocalStorage';
import { TPartialManga } from '@/typings.ts';
import { TManga, TPartialManga } from '@/typings.ts';
import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts';
import { DEFAULT_FULL_FAB_HEIGHT } from '@/components/util/StyledFab.tsx';
const GridContainer = React.forwardRef<HTMLDivElement, GridTypeMap['props']>(({ children, ...props }, ref) => (
<Grid {...props} ref={ref} container sx={{ paddingLeft: '5px', paddingRight: '13px' }}>
@@ -40,8 +42,22 @@ const GridItemContainerWithDimension = (
);
};
const createMangaCard = (manga: TPartialManga, gridLayout?: GridLayout, inLibraryIndicator?: boolean) => (
<MangaCard key={manga.id} manga={manga} gridLayout={gridLayout} inLibraryIndicator={inLibraryIndicator} />
const createMangaCard = (
manga: TPartialManga,
gridLayout?: GridLayout,
inLibraryIndicator?: boolean,
isSelectModeActive: boolean = false,
selectedMangaIds?: TManga['id'][],
handleSelection?: DefaultGridProps['handleSelection'],
) => (
<MangaCard
key={manga.id}
manga={manga}
gridLayout={gridLayout}
inLibraryIndicator={inLibraryIndicator}
selected={isSelectModeActive ? selectedMangaIds?.includes(manga.id) : null}
handleSelection={handleSelection}
/>
);
type DefaultGridProps = {
@@ -50,9 +66,21 @@ type DefaultGridProps = {
inLibraryIndicator?: boolean;
GridItemContainer: (props: GridTypeMap['props'] & Partial<GridItemProps>) => JSX.Element;
gridLayout?: GridLayout;
isSelectModeActive?: boolean;
selectedMangaIds?: Required<TManga['id']>[];
handleSelection?: SelectableCollectionReturnType<TManga['id']>['handleSelection'];
};
const HorizontalGrid = ({ isLoading, mangas, inLibraryIndicator, GridItemContainer, gridLayout }: DefaultGridProps) => (
const HorizontalGrid = ({
isLoading,
mangas,
inLibraryIndicator,
GridItemContainer,
gridLayout,
isSelectModeActive,
selectedMangaIds,
handleSelection,
}: DefaultGridProps) => (
<Grid
container
spacing={1}
@@ -70,7 +98,14 @@ const HorizontalGrid = ({ isLoading, mangas, inLibraryIndicator, GridItemContain
) : (
mangas.map((manga) => (
<GridItemContainer key={manga.id}>
{createMangaCard(manga, gridLayout, inLibraryIndicator)}
{createMangaCard(
manga,
gridLayout,
inLibraryIndicator,
isSelectModeActive,
selectedMangaIds,
handleSelection,
)}
</GridItemContainer>
))
)}
@@ -85,6 +120,9 @@ const VerticalGrid = ({
gridLayout,
hasNextPage,
loadMore,
isSelectModeActive,
selectedMangaIds,
handleSelection,
}: DefaultGridProps & {
hasNextPage: boolean;
loadMore: () => void;
@@ -125,26 +163,38 @@ const VerticalGrid = ({
restoreStateFrom={snapshot}
stateChanged={persistGridState}
endReached={() => loadMore()}
itemContent={(index) => createMangaCard(mangas[index], gridLayout, inLibraryIndicator)}
itemContent={(index) =>
createMangaCard(
mangas[index],
gridLayout,
inLibraryIndicator,
isSelectModeActive,
selectedMangaIds,
handleSelection,
)
}
/>
{/* render div to prevent UI jumping around when showing/hiding loading placeholder */
/* eslint-disable-next-line no-nested-ternary */}
{isLoading ? <LoadingPlaceholder /> : hasNextPage ? <div style={{ height: '75px' }} /> : null}
{isSelectModeActive && gridLayout === GridLayout.List ? (
<Box sx={{ paddingBottom: DEFAULT_FULL_FAB_HEIGHT }} />
) : // eslint-disable-next-line no-nested-ternary
isLoading ? (
<LoadingPlaceholder />
) : hasNextPage ? (
<div style={{ height: '75px' }} />
) : null}
</>
);
};
export interface IMangaGridProps {
mangas: TPartialManga[];
isLoading: boolean;
export interface IMangaGridProps extends Omit<DefaultGridProps, 'GridItemContainer'> {
message?: string;
messageExtra?: JSX.Element;
hasNextPage: boolean;
loadMore: () => void;
gridLayout?: GridLayout;
horizontal?: boolean | undefined;
noFaces?: boolean | undefined;
inLibraryIndicator?: boolean;
}
export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
@@ -159,6 +209,9 @@ export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
horizontal,
noFaces,
inLibraryIndicator,
isSelectModeActive,
selectedMangaIds,
handleSelection,
} = props;
const [dimensions, setDimensions] = useState(document.documentElement.offsetWidth);
@@ -221,6 +274,9 @@ export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
inLibraryIndicator={inLibraryIndicator}
GridItemContainer={GridItemContainer}
gridLayout={gridLayout}
isSelectModeActive={isSelectModeActive}
selectedMangaIds={selectedMangaIds}
handleSelection={handleSelection}
/>
) : (
<VerticalGrid
@@ -231,6 +287,9 @@ export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
hasNextPage={hasNextPage}
loadMore={loadMore}
gridLayout={gridLayout}
isSelectModeActive={isSelectModeActive}
selectedMangaIds={selectedMangaIds}
handleSelection={handleSelection}
/>
)}
</div>

View File

@@ -0,0 +1,49 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { Tooltip } from '@mui/material';
import Checkbox from '@mui/material/Checkbox';
import { useTranslation } from 'react-i18next';
import ClearIcon from '@mui/icons-material/Clear';
import { SelectableCollectionSelectAll } from '@/components/collection/SelectableCollectionSelectAll.tsx';
export const SelectableCollectionSelectMode = ({
isActive,
areAllItemsSelected,
areNoItemsSelected,
onSelectAll,
onModeChange,
}: {
isActive: boolean;
areAllItemsSelected: boolean;
areNoItemsSelected: boolean;
onSelectAll: (selectAll: boolean) => void;
onModeChange: (checked: boolean) => void;
}) => {
const { t } = useTranslation();
return (
<>
{isActive && (
<SelectableCollectionSelectAll
areAllItemsSelected={areAllItemsSelected}
areNoItemsSelected={areNoItemsSelected}
onChange={onSelectAll}
/>
)}
<Tooltip title={t(!isActive ? 'global.button.select_all' : 'global.button.cancel')}>
<Checkbox
checkedIcon={<ClearIcon />}
sx={{ padding: '8px' }}
checked={isActive}
onChange={(_, checked) => onModeChange(checked)}
/>
</Tooltip>
</>
);
};

View File

@@ -8,34 +8,92 @@
import { useState } from 'react';
export const useSelectableCollection = <Id extends number | string>(totalCount: number) => {
const [selectedItemIds, setSelectedItemIds] = useState<Id[]>([]);
export type SelectableCollectionReturnType<Id extends number | string, Key extends string = string> = {
selectedItemIds: Id[];
keySelectedItemIds: Id[];
areAllItemsSelected: boolean;
areNoItemsSelected: boolean;
areAllItemsForKeySelected: boolean;
areNoItemsForKeySelected: boolean;
handleSelection: (id: Id, selected: boolean, key?: Key) => void;
handleSelectAll: (selectAll: boolean, itemIds: Id[], key?: Key) => void;
setSelectionForKey: (key: Key, itemIds: Id[]) => void;
getSelectionForKey: (key: Key) => Id[];
};
export const useSelectableCollection = <Id extends number | string, Key extends string = 'default'>(
totalCount: number,
{
keyCount = totalCount,
currentKey,
initialState = {} as Record<Key, Id[]>,
}: {
keyCount?: number;
currentKey: Key;
initialState?: Record<Key, Id[]>;
},
): SelectableCollectionReturnType<Id, Key> => {
const [keyToSelectedItemIds, setKeyToSelectedItemIds] = useState<Record<string, Id[]>>(initialState);
const selectedItemIds = Object.values(keyToSelectedItemIds).flat();
const areAllItemsSelected = selectedItemIds.length === totalCount;
const areNoItemsSelected = !selectedItemIds.length;
const handleSelection = (id: Id, selected: boolean) => {
const keySelectedItemIds = keyToSelectedItemIds[currentKey] ?? [];
const areAllItemsForKeySelected = keySelectedItemIds.length === keyCount;
const areNoItemsForKeySelected = keySelectedItemIds.length === 0;
const handleSelection = (id: Id, selected: boolean, key: Key = currentKey) => {
const deselect = !selected;
if (deselect) {
setSelectedItemIds(selectedItemIds.filter((selectedItemId) => selectedItemId !== id));
setKeyToSelectedItemIds((prevState) => ({
...prevState,
[key]: prevState[key].filter((selectedItemId) => selectedItemId !== id),
}));
return;
}
setSelectedItemIds([...new Set([...selectedItemIds, id])]);
setKeyToSelectedItemIds((prevState) => ({
...prevState,
[key]: [...new Set([...(prevState[key] ?? []), id])],
}));
};
const handleSelectAll = (selectAll: boolean, itemIds: Id[]) => {
const handleSelectAll = (selectAll: boolean, itemIds: Id[], key: Key = currentKey) => {
switch (selectAll) {
case true:
setSelectedItemIds([...itemIds]);
setKeyToSelectedItemIds((prevState) => ({
...prevState,
[key]: [...itemIds],
}));
break;
case false:
setSelectedItemIds([]);
setKeyToSelectedItemIds((prevState) => ({
...prevState,
[key]: [],
}));
break;
default:
break;
}
};
return { selectedItemIds, handleSelection, handleSelectAll, areAllItemsSelected, areNoItemsSelected };
const setSelectionForKey = (key: Key, itemIds: Id[]) => {
keyToSelectedItemIds[key] = itemIds;
};
const getSelectionForKey = (key: Key) => keyToSelectedItemIds[key];
return {
selectedItemIds,
keySelectedItemIds,
handleSelection,
handleSelectAll,
areAllItemsSelected,
areNoItemsSelected,
areAllItemsForKeySelected,
areNoItemsForKeySelected,
setSelectionForKey,
getSelectionForKey,
};
};

View File

@@ -6,128 +6,35 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import React, { useEffect, useMemo } from 'react';
import React, { useEffect } from 'react';
import { StringParam, useQueryParam } from 'use-query-params';
import { useTranslation } from 'react-i18next';
import { LibrarySortMode, NullAndUndefined, TManga } from '@/typings';
import { useSearchSettings } from '@/util/searchSettings';
import { TManga } from '@/typings';
import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import { MangaGrid } from '@/components/MangaGrid';
import { IMangaGridProps, MangaGrid } from '@/components/MangaGrid';
const unreadFilter = (unread: NullAndUndefined<boolean>, { unreadCount }: TManga): boolean => {
switch (unread) {
case true:
return !!unreadCount && unreadCount >= 1;
case false:
return unreadCount === 0;
default:
return true;
}
};
const downloadedFilter = (downloaded: NullAndUndefined<boolean>, { downloadCount }: TManga): boolean => {
switch (downloaded) {
case true:
return !!downloadCount && downloadCount >= 1;
case false:
return downloadCount === 0;
default:
return true;
}
};
const queryFilter = (query: NullAndUndefined<string>, { title }: TManga): boolean => {
if (!query) return true;
return title.toLowerCase().includes(query.toLowerCase());
};
const queryGenreFilter = (query: NullAndUndefined<string>, { genre }: TManga): boolean => {
if (!query) return true;
const queries = query.split(',').map((str) => str.toLowerCase().trim());
return queries.every((element) => genre.map((el) => el.toLowerCase()).includes(element));
};
const filterManga = (
mangas: TManga[],
query: NullAndUndefined<string>,
unread: NullAndUndefined<boolean>,
downloaded: NullAndUndefined<boolean>,
ignoreFilters: boolean,
): TManga[] =>
mangas.filter((manga) => {
const ignoreFiltersWhileSearching = ignoreFilters && query?.length;
const matchesSearch = queryFilter(query, manga) || queryGenreFilter(query, manga);
const matchesFilters =
ignoreFiltersWhileSearching || (downloadedFilter(downloaded, manga) && unreadFilter(unread, manga));
return matchesSearch && matchesFilters;
});
const sortByUnread = (a: TManga, b: TManga): number => (a.unreadCount ?? 0) - (b.unreadCount ?? 0);
const sortByTitle = (a: TManga, b: TManga): number => a.title.localeCompare(b.title);
const sortByDateAdded = (a: TManga, b: TManga): number => Number(a.inLibraryAt) - Number(b.inLibraryAt);
const sortByLastRead = (a: TManga, b: TManga): number =>
Number(b.lastReadChapter?.lastReadAt ?? 0) - Number(a.lastReadChapter?.lastReadAt ?? 0);
const sortManga = (
manga: TManga[],
sort: NullAndUndefined<LibrarySortMode>,
desc: NullAndUndefined<boolean>,
): TManga[] => {
const result = [...manga];
switch (sort) {
case 'sortAlph':
result.sort(sortByTitle);
break;
case 'sortDateAdded':
result.sort(sortByDateAdded);
break;
case 'sortToRead':
result.sort(sortByUnread);
break;
case 'sortLastRead':
result.sort(sortByLastRead);
break;
default:
break;
}
if (desc === true) {
result.reverse();
}
return result;
};
interface LibraryMangaGridProps {
interface LibraryMangaGridProps
extends Required<Pick<IMangaGridProps, 'isSelectModeActive' | 'selectedMangaIds' | 'handleSelection'>> {
mangas: TManga[];
showFilteredOutMessage: boolean;
isLoading: boolean;
message?: string;
}
export const LibraryMangaGrid: React.FC<LibraryMangaGridProps> = ({ mangas, isLoading, message }) => {
export const LibraryMangaGrid: React.FC<LibraryMangaGridProps> = ({
mangas,
showFilteredOutMessage,
isLoading,
message,
isSelectModeActive,
selectedMangaIds,
handleSelection,
}) => {
const { t } = useTranslation();
const [query] = useQueryParam('query', StringParam);
const { options } = useLibraryOptionsContext();
const { unread, downloaded } = options;
const { settings } = useSearchSettings();
const filteredMangas = useMemo(
() => filterManga(mangas, query, unread, downloaded, settings.ignoreFilters),
[mangas, query, unread, downloaded, settings.ignoreFilters],
);
const sortedMangas = useMemo(
() => sortManga(filteredMangas, options.sorts, options.sortDesc),
[filteredMangas, options.sorts, options.sortDesc],
);
const showFilteredOutMessage =
(unread != null || downloaded != null || query) && filteredMangas.length === 0 && mangas.length > 0;
useEffect(() => {
window.scrollTo(0, 0);
@@ -135,12 +42,15 @@ export const LibraryMangaGrid: React.FC<LibraryMangaGridProps> = ({ mangas, isLo
return (
<MangaGrid
mangas={sortedMangas}
mangas={mangas}
isLoading={isLoading}
hasNextPage={false}
loadMore={() => undefined}
message={showFilteredOutMessage ? t('library.error.label.no_matches') : message}
gridLayout={options.gridLayout}
isSelectModeActive={isSelectModeActive}
selectedMangaIds={selectedMangaIds}
handleSelection={handleSelection}
/>
);
};

View File

@@ -0,0 +1,126 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { StringParam, useQueryParam } from 'use-query-params';
import { useMemo } from 'react';
import { LibrarySortMode, NullAndUndefined, TManga } from '@/typings.ts';
import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext.tsx';
import { useSearchSettings } from '@/util/searchSettings.ts';
const unreadFilter = (unread: NullAndUndefined<boolean>, { unreadCount }: TManga): boolean => {
switch (unread) {
case true:
return !!unreadCount && unreadCount >= 1;
case false:
return unreadCount === 0;
default:
return true;
}
};
const downloadedFilter = (downloaded: NullAndUndefined<boolean>, { downloadCount }: TManga): boolean => {
switch (downloaded) {
case true:
return !!downloadCount && downloadCount >= 1;
case false:
return downloadCount === 0;
default:
return true;
}
};
const queryFilter = (query: NullAndUndefined<string>, { title }: TManga): boolean => {
if (!query) return true;
return title.toLowerCase().includes(query.toLowerCase());
};
const queryGenreFilter = (query: NullAndUndefined<string>, { genre }: TManga): boolean => {
if (!query) return true;
const queries = query.split(',').map((str) => str.toLowerCase().trim());
return queries.every((element) => genre.map((el) => el.toLowerCase()).includes(element));
};
const filterManga = (
mangas: TManga[],
query: NullAndUndefined<string>,
unread: NullAndUndefined<boolean>,
downloaded: NullAndUndefined<boolean>,
ignoreFilters: boolean,
): TManga[] =>
mangas.filter((manga) => {
const ignoreFiltersWhileSearching = ignoreFilters && query?.length;
const matchesSearch = queryFilter(query, manga) || queryGenreFilter(query, manga);
const matchesFilters =
ignoreFiltersWhileSearching || (downloadedFilter(downloaded, manga) && unreadFilter(unread, manga));
return matchesSearch && matchesFilters;
});
const sortByUnread = (a: TManga, b: TManga): number => (a.unreadCount ?? 0) - (b.unreadCount ?? 0);
const sortByTitle = (a: TManga, b: TManga): number => a.title.localeCompare(b.title);
const sortByDateAdded = (a: TManga, b: TManga): number => Number(a.inLibraryAt) - Number(b.inLibraryAt);
const sortByLastRead = (a: TManga, b: TManga): number =>
Number(b.lastReadChapter?.lastReadAt ?? 0) - Number(a.lastReadChapter?.lastReadAt ?? 0);
const sortManga = (
manga: TManga[],
sort: NullAndUndefined<LibrarySortMode>,
desc: NullAndUndefined<boolean>,
): TManga[] => {
const result = [...manga];
switch (sort) {
case 'sortAlph':
result.sort(sortByTitle);
break;
case 'sortDateAdded':
result.sort(sortByDateAdded);
break;
case 'sortToRead':
result.sort(sortByUnread);
break;
case 'sortLastRead':
result.sort(sortByLastRead);
break;
default:
break;
}
if (desc === true) {
result.reverse();
}
return result;
};
export const useGetVisibleLibraryMangas = (mangas: TManga[]) => {
const [query] = useQueryParam('query', StringParam);
const { options } = useLibraryOptionsContext();
const { unread, downloaded } = options;
const { settings } = useSearchSettings();
const filteredMangas = useMemo(
() => filterManga(mangas, query, unread, downloaded, settings.ignoreFilters),
[mangas, query, unread, downloaded, settings.ignoreFilters],
);
const sortedMangas = useMemo(
() => sortManga(filteredMangas, options.sorts, options.sortDesc),
[filteredMangas, options.sorts, options.sortDesc],
);
const showFilteredOutMessage =
(unread != null || downloaded != null || !!query) && filteredMangas.length === 0 && mangas.length > 0;
return {
visibleMangas: sortedMangas,
showFilteredOutMessage,
};
};

View File

@@ -106,7 +106,7 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
const chapters = useMemo(() => chaptersData?.chapters.nodes ?? [], [chaptersData?.chapters.nodes]);
const { areNoItemsSelected, areAllItemsSelected, selectedItemIds, handleSelectAll, handleSelection } =
useSelectableCollection(chapters.length);
useSelectableCollection(chapters.length, { currentKey: 'default' });
const { settings: metadataServerSettings } = useMetadataServerSettings();

View File

@@ -0,0 +1,127 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { ListItemIcon, ListItemText } from '@mui/material';
import CheckBoxOutlineBlank from '@mui/icons-material/CheckBoxOutlineBlank';
import Delete from '@mui/icons-material/Delete';
import Download from '@mui/icons-material/Download';
import RemoveDone from '@mui/icons-material/RemoveDone';
import Done from '@mui/icons-material/Done';
import { useTranslation } from 'react-i18next';
import { bindMenu } from 'material-ui-popup-state';
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
import Label from '@mui/icons-material/Label';
import { useState } from 'react';
import { TManga } from '@/typings.ts';
import { MangaAction, MangaDownloadInfo, Mangas, MangaUnreadInfo } from '@/lib/data/Mangas.ts';
import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts';
import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
import { CategorySelect } from '@/components/navbar/action/CategorySelect.tsx';
export const MangaActionMenu = ({
manga,
handleSelection,
...bindMenuProps
}: {
manga: Pick<TManga, 'id'> & MangaDownloadInfo & MangaUnreadInfo;
handleSelection?: SelectableCollectionReturnType<TManga['id']>['handleSelection'];
} & ReturnType<typeof bindMenu>) => {
const { t } = useTranslation();
const { settings } = useMetadataServerSettings();
const [isCategorySelectOpen, setIsCategorySelectOpen] = useState(false);
const isFullyDownloaded = manga.downloadCount === manga.chapters.totalCount;
const hasDownloadedChapters = !!manga.downloadCount;
const hasUnreadChapters = !!manga.unreadCount;
const hasReadChapters = manga.unreadCount !== manga.chapters.totalCount;
const handleSelect = () => {
handleSelection?.(manga.id, true);
bindMenuProps.onClose();
};
const performAction = (action: MangaAction) => {
Mangas.performAction(action, [manga.id], {
autoDeleteChapters: settings.deleteChaptersManuallyMarkedRead,
}).catch(() => {});
bindMenuProps.onClose();
};
return (
<>
<Menu {...bindMenuProps} open={bindMenuProps.open && !isCategorySelectOpen}>
{!!handleSelection && (
<MenuItem onClick={handleSelect}>
<ListItemIcon>
<CheckBoxOutlineBlank fontSize="small" />
</ListItemIcon>
<ListItemText>{t('chapter.action.label.select')}</ListItemText>
</MenuItem>
)}
{!isFullyDownloaded && (
<MenuItem onClick={() => performAction('download')}>
<ListItemIcon>
<Download fontSize="small" />
</ListItemIcon>
<ListItemText>{t('chapter.action.download.add.label.action')}</ListItemText>
</MenuItem>
)}
{hasDownloadedChapters && (
<MenuItem onClick={() => performAction('delete')}>
<ListItemIcon>
<Delete fontSize="small" />
</ListItemIcon>
<ListItemText>{t('chapter.action.download.delete.label.action')}</ListItemText>
</MenuItem>
)}
{hasUnreadChapters && (
<MenuItem onClick={() => performAction('mark_as_read')}>
<ListItemIcon>
<Done fontSize="small" />
</ListItemIcon>
<ListItemText>{t('chapter.action.mark_as_read.add.label.action.current')}</ListItemText>
</MenuItem>
)}
{hasReadChapters && (
<MenuItem onClick={() => performAction('mark_as_unread')}>
<ListItemIcon>
<RemoveDone fontSize="small" />
</ListItemIcon>
<ListItemText>{t('chapter.action.mark_as_read.remove.label.action')}</ListItemText>
</MenuItem>
)}
<MenuItem onClick={() => setIsCategorySelectOpen(true)}>
<ListItemIcon>
<Label fontSize="small" />
</ListItemIcon>
<ListItemText>{t('manga.action.category.label.action')}</ListItemText>
</MenuItem>
<MenuItem onClick={() => performAction('remove_from_library')}>
<ListItemIcon>
<FavoriteBorderIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t('manga.action.library.remove.label.action')}</ListItemText>
</MenuItem>
</Menu>
{isCategorySelectOpen && (
<CategorySelect
open={isCategorySelectOpen}
setOpen={(open) => {
setIsCategorySelectOpen(open);
bindMenuProps.onClose();
}}
mangaId={manga.id}
/>
)}
</>
);
};

View File

@@ -0,0 +1,114 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useTranslation } from 'react-i18next';
import React, { TouchEvent, useMemo } from 'react';
import { Button, Tooltip } from '@mui/material';
import Checkbox from '@mui/material/Checkbox';
import IconButton from '@mui/material/IconButton';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import { PopupState } from 'material-ui-popup-state/es/hooks';
import { bindTrigger } from 'material-ui-popup-state';
import { isMobile } from 'react-device-detect';
import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts';
import { TManga } from '@/typings.ts';
export const MangaOptionButton = ({
id,
selected,
handleSelection,
asCheckbox = false,
popupState,
}: {
id: number;
selected?: boolean | null;
handleSelection?: SelectableCollectionReturnType<TManga['id']>['handleSelection'];
asCheckbox?: boolean;
popupState: PopupState;
}) => {
const { t } = useTranslation();
const bindTriggerProps = useMemo(() => bindTrigger(popupState), [popupState]);
const preventDefaultAction = (e: React.BaseSyntheticEvent<unknown>) => {
e.stopPropagation();
e.preventDefault();
};
const handleSelectionChange = (e: React.BaseSyntheticEvent<unknown>, isSelected: boolean) => {
preventDefaultAction(e);
handleSelection?.(id, isSelected);
};
const handleClick = (e: React.BaseSyntheticEvent<unknown>) => {
preventDefaultAction(e);
bindTriggerProps.onClick(e as any);
};
const handleTouchStart = (e: React.BaseSyntheticEvent<unknown>) => {
preventDefaultAction(e);
bindTriggerProps.onTouchStart(e as TouchEvent);
};
if (!handleSelection) {
return null;
}
const isSelected = selected !== null;
if (isSelected) {
if (!asCheckbox) {
return null;
}
return (
<Tooltip title={t(selected ? 'global.button.deselect' : 'global.button.select')}>
<Checkbox checked={selected} onMouseDown={preventDefaultAction} onChange={handleSelectionChange} />
</Tooltip>
);
}
if (asCheckbox) {
return (
<Tooltip title={t('global.button.options')}>
<IconButton
{...bindTriggerProps}
onClick={handleClick}
onTouchStart={handleTouchStart}
aria-label="more"
size="large"
onMouseDown={preventDefaultAction}
>
<MoreVertIcon />
</IconButton>
</Tooltip>
);
}
return (
<Tooltip title={t('global.button.options')}>
<Button
{...bindTriggerProps}
onClick={handleClick}
onTouchStart={handleTouchStart}
className="manga-option-button"
size="small"
variant="contained"
sx={{
minWidth: 'unset',
paddingX: '0',
paddingY: '2.5px',
visibility: popupState.isOpen || isMobile ? 'visible' : 'hidden',
pointerEvents: isMobile ? undefined : 'none',
}}
onMouseDown={(e) => e.stopPropagation()}
>
<MoreVertIcon />
</Button>
</Tooltip>
);
};

View File

@@ -0,0 +1,105 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import Download from '@mui/icons-material/Download';
import Delete from '@mui/icons-material/Delete';
import Done from '@mui/icons-material/Done';
import RemoveDone from '@mui/icons-material/RemoveDone';
import { useTranslation } from 'react-i18next';
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
import Label from '@mui/icons-material/Label';
import { useState } from 'react';
import { SelectionFABActionItem } from '@/components/manga/SelectionFABActionItem.tsx';
import { TManga } from '@/typings.ts';
import { MangaAction, Mangas } from '@/lib/data/Mangas.ts';
import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
import { CategorySelect } from '@/components/navbar/action/CategorySelect.tsx';
const ACTION_DISABLES_SELECTION_MODE: MangaAction[] = ['remove_from_library'] as const;
export const MangasSelectionFABActionItems = ({
selectedMangas,
handleClose,
}: {
selectedMangas: TManga[];
handleClose: (selectionModeState: boolean) => void;
}) => {
const { t } = useTranslation();
const { settings } = useMetadataServerSettings();
const [isCategorySelectOpen, setIsCategorySelectOpen] = useState(false);
const handleAction = (action: MangaAction, mangas: TManga[]) => {
Mangas.performAction(action, Mangas.getIds(mangas), {
autoDeleteChapters: settings.deleteChaptersManuallyMarkedRead,
}).catch(() => {});
handleClose(!ACTION_DISABLES_SELECTION_MODE.includes(action));
};
return (
<>
<SelectionFABActionItem<MangaAction, TManga>
action="download"
Icon={Download}
matchingItems={[
...Mangas.getNotDownloaded(selectedMangas),
...Mangas.getPartiallyDownloaded(selectedMangas),
]}
onClick={handleAction}
title={t('chapter.action.download.add.button.selected')}
/>
<SelectionFABActionItem<MangaAction, TManga>
action="delete"
Icon={Delete}
matchingItems={[
...Mangas.getPartiallyDownloaded(selectedMangas),
...Mangas.getFullyDownloaded(selectedMangas),
]}
onClick={handleAction}
title={t('chapter.action.download.delete.button.selected')}
/>
<SelectionFABActionItem<MangaAction, TManga>
action="mark_as_read"
Icon={Done}
matchingItems={[...Mangas.getUnread(selectedMangas), ...Mangas.getPartiallyRead(selectedMangas)]}
onClick={handleAction}
title={t('chapter.action.mark_as_read.add.button.selected')}
/>
<SelectionFABActionItem<MangaAction, TManga>
action="mark_as_unread"
Icon={RemoveDone}
matchingItems={[...Mangas.getPartiallyRead(selectedMangas), ...Mangas.getFullyRead(selectedMangas)]}
onClick={handleAction}
title={t('chapter.action.mark_as_read.remove.button.selected')}
/>
<SelectionFABActionItem<MangaAction, TManga>
action="change_categories"
Icon={Label}
matchingItems={selectedMangas}
onClick={() => setIsCategorySelectOpen(true)}
title={t('manga.action.category.label.action')}
/>
<SelectionFABActionItem<MangaAction, TManga>
action="remove_from_library"
Icon={FavoriteBorderIcon}
matchingItems={[...Mangas.getPartiallyRead(selectedMangas), ...Mangas.getFullyRead(selectedMangas)]}
onClick={handleAction}
title={t('manga.action.library.remove.button.selected')}
/>
{isCategorySelectOpen && (
<CategorySelect
open={isCategorySelectOpen}
setOpen={(open) => {
setIsCategorySelectOpen(open);
handleClose(true);
}}
mangaIds={Mangas.getIds(selectedMangas)}
/>
)}
</>
);
};

View File

@@ -7,7 +7,7 @@
*/
import MoreHoriz from '@mui/icons-material/MoreHoriz';
import { Fab, Menu, Box } from '@mui/material';
import { Fab, Menu, Box, styled } from '@mui/material';
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DEFAULT_FAB_STYLE } from '@/components/util/StyledFab';
@@ -19,6 +19,16 @@ interface SelectionFABProps {
title: TranslationKey;
}
const FabContainer = styled(Box)(({ theme }) => ({
...DEFAULT_FAB_STYLE,
height: `calc(${DEFAULT_FAB_STYLE.height} + 1)`,
paddingTop: '8px',
zIndex: 1, // the "Checkbox" (MUI) component of the "ChapterCard" has z-index 1, which causes it to take over the mouse events
[theme.breakpoints.down('md')]: {
marginBottom: '64px',
},
}));
export const SelectionFAB: React.FC<SelectionFABProps> = ({ children, selectedItemsCount, title }) => {
const { t } = useTranslation();
@@ -27,15 +37,7 @@ export const SelectionFAB: React.FC<SelectionFABProps> = ({ children, selectedIt
const handleClose = () => setOpen(false);
return (
<Box
sx={{
...DEFAULT_FAB_STYLE,
height: `calc(${DEFAULT_FAB_STYLE.height} + 1)`,
pt: 1,
zIndex: 1, // the "Checkbox" (MUI) component of the "ChapterCard" has z-index 1, which causes it to take over the mouse events
}}
ref={anchorEl}
>
<FabContainer ref={anchorEl}>
<Fab variant="extended" color="primary" id="selectionMenuButton" onClick={() => setOpen(true)}>
{`${selectedItemsCount} ${t(title, { count: selectedItemsCount })}`}
<MoreHoriz sx={{ ml: 1 }} />
@@ -53,6 +55,6 @@ export const SelectionFAB: React.FC<SelectionFABProps> = ({ children, selectedIt
>
{children(handleClose)}
</Menu>
</Box>
</FabContainer>
);
};

View File

@@ -6,33 +6,80 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import React, { useMemo } from 'react';
import { useMemo } from 'react';
import Button from '@mui/material/Button';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Dialog from '@mui/material/Dialog';
import Checkbox from '@mui/material/Checkbox';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormGroup from '@mui/material/FormGroup';
import { useTranslation } from 'react-i18next';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { Mangas } from '@/lib/data/Mangas.ts';
import { useSelectableCollection } from '@/components/collection/useSelectableCollection.ts';
import { ThreeStateCheckboxInput } from '@/components/atoms/ThreeStateCheckboxInput.tsx';
interface IProps {
type BaseProps = {
open: boolean;
setOpen: (value: boolean) => void;
mangaId: number;
}
};
export function CategorySelect(props: IProps) {
type SingleMangaModeProps = {
mangaId: number;
};
type MultiMangaModeProps = {
mangaIds: number[];
};
type Props =
| (BaseProps & SingleMangaModeProps & PropertiesNever<MultiMangaModeProps>)
| (BaseProps & PropertiesNever<SingleMangaModeProps> & MultiMangaModeProps);
const useGetMangaCategoryIds = (mangaId: number | undefined): number[] => {
const { data: mangaResult } = requestManager.useGetManga(mangaId ?? -1, { skip: mangaId === undefined });
return useMemo(() => {
if (mangaId === undefined || !mangaResult) {
return [];
}
return mangaResult.manga.categories.nodes.map((category) => category.id);
}, [mangaResult?.manga.categories.nodes, mangaId]);
};
const getCategoryCheckedState = (
categoryId: number,
categoriesToAdd: number[],
categoriesToRemove: number[],
isSingleSelectionMode: boolean,
): boolean | undefined => {
if (categoriesToAdd.includes(categoryId)) {
return true;
}
if (isSingleSelectionMode) {
return undefined;
}
if (categoriesToRemove.includes(categoryId)) {
return false;
}
return undefined;
};
export function CategorySelect(props: Props) {
const { t } = useTranslation();
const { open, setOpen, mangaId } = props;
const { open, setOpen, mangaId, mangaIds: passedMangaIds } = props;
const { data: mangaResult } = requestManager.useGetManga(mangaId);
const isSingleSelectionMode = mangaId !== undefined;
const mangaIds = passedMangaIds ?? [mangaId];
const mangaCategories = useGetMangaCategoryIds(mangaId);
const { data } = requestManager.useGetCategories();
const categoriesData = data?.categories.nodes;
const [triggerMutate] = requestManager.useUpdateMangaCategories();
const allCategories = useMemo(() => {
const cats = [...(categoriesData ?? [])]; // make copy
@@ -42,29 +89,45 @@ export function CategorySelect(props: IProps) {
return cats;
}, [categoriesData]);
const selectedIds = mangaResult?.manga.categories.nodes.map((c) => c.id) ?? [];
const { handleSelection, setSelectionForKey, getSelectionForKey } = useSelectableCollection<
number,
'categoriesToAdd' | 'categoriesToRemove'
>(allCategories.length, {
currentKey: 'categoriesToAdd',
initialState: {
categoriesToAdd: mangaCategories,
categoriesToRemove: [],
},
});
const categoriesToAdd = getSelectionForKey('categoriesToAdd');
const categoriesToRemove = getSelectionForKey('categoriesToRemove');
const handleCancel = () => {
setSelectionForKey('categoriesToAdd', mangaCategories);
setSelectionForKey('categoriesToRemove', []);
setOpen(false);
};
const handleOk = () => {
setOpen(false);
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>, categoryId: number) => {
const { checked } = event.target as HTMLInputElement;
const addToCategories = isSingleSelectionMode
? categoriesToAdd.filter((categoryId) => !mangaCategories.includes(categoryId))
: categoriesToAdd;
const removeFromCategories = isSingleSelectionMode
? mangaCategories.filter((categoryId) => !categoriesToAdd.includes(categoryId))
: categoriesToRemove;
// TODO - update to only update categories when clicking OK - can now be updated in one go with graphql
triggerMutate({
variables: {
input: {
id: mangaId,
patch: {
addToCategories: checked ? [categoryId] : [],
removeFromCategories: !checked ? [categoryId] : [],
},
},
const isUpdateRequired = !!addToCategories.length || !!removeFromCategories.length;
if (!isUpdateRequired) {
return;
}
Mangas.performAction('change_categories', mangaIds, {
changeCategoriesPatch: {
addToCategories,
removeFromCategories,
},
});
};
@@ -91,14 +154,25 @@ export function CategorySelect(props: IProps) {
</span>
)}
{allCategories.map((category) => (
<FormControlLabel
control={
<Checkbox
checked={selectedIds.includes(category.id)}
onChange={(e) => handleChange(e, category.id)}
color="default"
/>
}
<ThreeStateCheckboxInput
checked={getCategoryCheckedState(
category.id,
categoriesToAdd,
categoriesToRemove,
isSingleSelectionMode,
)}
onChange={(checked) => {
handleSelection(category.id, false, 'categoriesToAdd');
handleSelection(category.id, false, 'categoriesToRemove');
if (checked) {
handleSelection(category.id, true, 'categoriesToAdd');
}
if (checked === false) {
handleSelection(category.id, true, 'categoriesToRemove');
}
}}
label={category.name}
key={category.id}
/>

View File

@@ -446,6 +446,34 @@
"title": "Library"
},
"manga": {
"action": {
"library": {
"remove": {
"button": {
"selected": "Remove selected from the library"
},
"label": {
"action": "Remove from the library",
"error_one": "Could not remove manga from the library",
"error_other": "Could not remove manga from the library",
"success_one": "Removed manga from the library",
"success_other": "Removed {{count}} manga from the library"
}
}
},
"category": {
"button": {
"selected": "Change categories of selected"
},
"label": {
"action": "Change categories",
"error_one": "Could not change the categories of the manga",
"error_other": "Could not change the categories of the manga",
"success_one": "Changed categories of manga",
"success_other": "Changed categories of {{count}} manga"
}
}
},
"button": {
"add_to_library": "Add To Library",
"in_library": "In Library"
@@ -464,7 +492,9 @@
"reload_from_source": "Reload data from source",
"status": "Status"
},
"title": "Manga"
"title": "Manga",
"title_one": "Manga",
"title_other": "Manga"
},
"reader": {
"button": {

36
src/lib/data/Chapters.ts Normal file
View File

@@ -0,0 +1,36 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { TChapter } from '@/typings.ts';
type ChapterDownloadInfo = Pick<TChapter, 'isDownloaded'>;
type ChapterBookmarkInfo = Pick<TChapter, 'isBookmarked'>;
export class Chapters {
static getIds(chapters: { id: number }[]): number[] {
return chapters.map((chapter) => chapter.id);
}
static isDeletable({ isDownloaded }: ChapterDownloadInfo): boolean {
return isDownloaded;
}
static isAutoDeletable(
{ isBookmarked, ...chapter }: ChapterDownloadInfo & ChapterBookmarkInfo,
canDeleteBookmarked: boolean = false,
): boolean {
return Chapters.isDeletable(chapter) && (!isBookmarked || canDeleteBookmarked);
}
static getAutoDeletable<Chapters extends ChapterDownloadInfo & ChapterBookmarkInfo>(
chapters: Chapters[],
canDeleteBookmarked?: boolean,
): Chapters[] {
return chapters.filter((chapter) => Chapters.isAutoDeletable(chapter, canDeleteBookmarked));
}
}

227
src/lib/data/Mangas.ts Normal file
View File

@@ -0,0 +1,227 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { t as translate } from 'i18next';
import { TManga, TranslationKey } from '@/typings.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import {
ChapterConditionInput,
GetMangasChapterIdsWithStateQuery,
UpdateMangaCategoriesPatchInput,
} from '@/lib/graphql/generated/graphql.ts';
import { Chapters } from '@/lib/data/Chapters.ts';
import { getMetadataServerSettings } from '@/util/metadataServerSettings.ts';
import { makeToast } from '@/components/util/Toast.tsx';
export type MangaAction =
| 'download'
| 'delete'
| 'mark_as_read'
| 'mark_as_unread'
| 'remove_from_library'
| 'change_categories';
const actionToTranslationKey: {
[key in MangaAction]: {
success: TranslationKey;
error: TranslationKey;
};
} = {
download: {
success: 'chapter.action.download.add.label.success',
error: 'chapter.action.download.add.label.error',
},
delete: {
success: 'chapter.action.download.delete.label.success',
error: 'chapter.action.download.delete.label.error',
},
mark_as_read: {
success: 'chapter.action.mark_as_read.add.label.success',
error: 'chapter.action.mark_as_read.add.label.error',
},
mark_as_unread: {
success: 'chapter.action.mark_as_read.remove.label.success',
error: 'chapter.action.mark_as_read.remove.label.error',
},
remove_from_library: {
success: 'manga.action.library.remove.label.success',
error: 'manga.action.library.remove.label.error',
},
change_categories: {
success: 'manga.action.category.label.success',
error: 'manga.action.category.label.error',
},
};
export type MangaChapterCountInfo = { chapters: Pick<TManga['chapters'], 'totalCount'> };
export type MangaDownloadInfo = Pick<TManga, 'downloadCount'> & MangaChapterCountInfo;
export type MangaUnreadInfo = Pick<TManga, 'unreadCount'> & MangaChapterCountInfo;
export class Mangas {
static getIds(mangas: { id: number }[]): number[] {
return mangas.map((manga) => manga.id);
}
static isNotDownloaded({ downloadCount }: MangaDownloadInfo): boolean {
return downloadCount === 0;
}
static getNotDownloaded<Mangas extends MangaDownloadInfo>(mangas: Mangas[]): Mangas[] {
return mangas.filter(Mangas.isNotDownloaded);
}
static isFullyDownloaded({ downloadCount, chapters: { totalCount } }: MangaDownloadInfo): boolean {
return downloadCount === totalCount;
}
static getFullyDownloaded<Mangas extends MangaDownloadInfo>(mangas: Mangas[]): Mangas[] {
return mangas.filter(Mangas.isFullyDownloaded);
}
static isPartiallyDownloaded(manga: MangaDownloadInfo): boolean {
return !Mangas.isNotDownloaded(manga) && !Mangas.isFullyDownloaded(manga);
}
static getPartiallyDownloaded<Mangas extends MangaDownloadInfo>(mangas: Mangas[]): Mangas[] {
return mangas.filter(Mangas.isPartiallyDownloaded);
}
static isUnread({ unreadCount, chapters: { totalCount } }: MangaUnreadInfo): boolean {
return unreadCount === totalCount;
}
static getUnread<Mangas extends MangaUnreadInfo>(mangas: Mangas[]): Mangas[] {
return mangas.filter(Mangas.isUnread);
}
static isFullyRead({ unreadCount }: MangaUnreadInfo): boolean {
return unreadCount === 0;
}
static getFullyRead<Mangas extends MangaUnreadInfo>(mangas: Mangas[]): Mangas[] {
return mangas.filter(Mangas.isFullyRead);
}
static isPartiallyRead(manga: MangaUnreadInfo): boolean {
return !Mangas.isUnread(manga) && !Mangas.isFullyRead(manga);
}
static getPartiallyRead<Mangas extends MangaUnreadInfo>(mangas: Mangas[]): Mangas[] {
return mangas.filter(Mangas.isPartiallyRead);
}
static async getChapterIdsWithState(
mangaIds: number[],
state: Pick<ChapterConditionInput, 'isRead' | 'isDownloaded' | 'isBookmarked'>,
): Promise<GetMangasChapterIdsWithStateQuery['chapters']['nodes']> {
const { data } = await requestManager.getMangasChapterIdsWithState(mangaIds, state).response;
return data.chapters.nodes;
}
static async downloadChapters(mangaIds: number[]): Promise<void> {
const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isDownloaded: false });
return Mangas.executeAction(
'download',
chapters.length,
() => requestManager.addChaptersToDownloadQueue(Chapters.getIds(chapters)).response,
);
}
static async deleteChapters(mangaIds: number[]): Promise<void> {
const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isDownloaded: true });
return Mangas.executeAction(
'delete',
chapters.length,
() => requestManager.deleteDownloadedChapters(Chapters.getIds(chapters)).response,
);
}
static async markAsRead(mangaIds: number[], deleteChapters: boolean = false): Promise<void> {
const [chapters, { deleteChaptersWithBookmark }] = await Promise.all([
Mangas.getChapterIdsWithState(mangaIds, { isRead: false }),
getMetadataServerSettings(),
]);
const chapterIdsToDelete = deleteChapters
? Chapters.getIds(Chapters.getAutoDeletable(chapters, deleteChaptersWithBookmark))
: [];
return Mangas.executeAction(
'mark_as_read',
chapterIdsToDelete.length,
() =>
requestManager.updateChapters(Chapters.getIds(chapters), { isRead: true, chapterIdsToDelete }).response,
);
}
static async markAsUnread(mangaIds: number[]): Promise<void> {
const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isRead: true });
return Mangas.executeAction(
'mark_as_unread',
chapters.length,
() => requestManager.updateChapters(Chapters.getIds(chapters), { isRead: false }).response,
);
}
static async removeFromLibrary(mangaIds: number[]): Promise<void> {
return Mangas.executeAction(
'remove_from_library',
mangaIds.length,
() => requestManager.updateMangas(mangaIds, { inLibrary: false }).response,
);
}
static async changeCategories(mangaIds: number[], patch: UpdateMangaCategoriesPatchInput): Promise<void> {
return Mangas.executeAction(
'change_categories',
mangaIds.length,
() => requestManager.updateMangasCategories(mangaIds, patch).response,
);
}
private static async executeAction(
action: MangaAction,
itemCount: number,
fnToExecute: () => Promise<unknown>,
): Promise<void> {
try {
await fnToExecute();
makeToast(translate(actionToTranslationKey[action].success, { count: itemCount }), 'success');
} catch (e) {
makeToast(translate(actionToTranslationKey[action].error, { count: itemCount }), 'error');
throw e;
}
}
static async performAction<Action extends MangaAction>(
action: Action,
mangaIds: number[],
{
autoDeleteChapters,
changeCategoriesPatch,
}: Action extends 'mark_as_read'
? { autoDeleteChapters: boolean; changeCategoriesPatch?: never }
: Action extends 'change_categories'
? { autoDeleteChapters?: never; changeCategoriesPatch: UpdateMangaCategoriesPatchInput }
: { autoDeleteChapters?: boolean; changeCategoriesPatch?: UpdateMangaCategoriesPatchInput },
): Promise<void> {
switch (action) {
case 'download':
return Mangas.downloadChapters(mangaIds);
case 'delete':
return Mangas.deleteChapters(mangaIds);
case 'mark_as_read':
return Mangas.markAsRead(mangaIds, autoDeleteChapters!);
case 'mark_as_unread':
return Mangas.markAsUnread(mangaIds);
case 'remove_from_library':
return Mangas.removeFromLibrary(mangaIds);
case 'change_categories':
return Mangas.changeCategories(mangaIds, changeCategoriesPatch!);
default:
throw new Error(`performMangasAction::performAction: unknown action "${action}"`);
}
}
}

View File

@@ -2581,6 +2581,16 @@ export type GetChaptersQueryVariables = Exact<{
export type GetChaptersQuery = { __typename?: 'Query', chapters: { __typename?: 'ChapterNodeList', totalCount: number, nodes: Array<{ __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', unreadCount: number, downloadCount: number, artist?: string | null, author?: string | null, chaptersLastFetchedAt?: any | null, description?: string | null, genre: Array<string>, id: number, inLibrary: boolean, inLibraryAt: any, initialized: boolean, lastFetchedAt?: any | null, realUrl?: string | null, status: MangaStatus, thumbnailUrl?: string | null, title: string, url: string, categories: { __typename?: 'CategoryNodeList', totalCount: number, nodes: Array<{ __typename?: 'CategoryType', default: boolean, id: number, includeInUpdate: IncludeInUpdate, name: string, order: number, meta: Array<{ __typename?: 'CategoryMetaType', key: string, value: string }>, mangas: { __typename?: 'MangaNodeList', totalCount: number } }> }, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, meta: Array<{ __typename?: 'MangaMetaType', key: string, value: string }>, source?: { __typename?: 'SourceType', displayName: string, iconUrl: string, id: any, isConfigurable: boolean, isNsfw: boolean, lang: string, name: string, supportsLatest: boolean } | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> }>, pageInfo: { __typename?: 'PageInfo', endCursor?: any | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: any | null } } };
export type GetMangasChapterIdsWithStateQueryVariables = Exact<{
mangaIds: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
isDownloaded?: InputMaybe<Scalars['Boolean']['input']>;
isRead?: InputMaybe<Scalars['Boolean']['input']>;
isBookmarked?: InputMaybe<Scalars['Boolean']['input']>;
}>;
export type GetMangasChapterIdsWithStateQuery = { __typename?: 'Query', chapters: { __typename?: 'ChapterNodeList', nodes: Array<{ __typename?: 'ChapterType', id: number, isDownloaded: boolean, isRead: boolean, isBookmarked: boolean }> } };
export type GetDownloadStatusQueryVariables = Exact<{ [key: string]: never; }>;

View File

@@ -55,3 +55,24 @@ export const GET_CHAPTERS = gql`
}
}
`;
export const GET_MANGAS_CHAPTER_IDS_WITH_STATE = gql`
query GET_MANGAS_CHAPTER_IDS_WITH_STATE(
$mangaIds: [Int!]!
$isDownloaded: Boolean = null
$isRead: Boolean = null
$isBookmarked: Boolean = null
) {
chapters(
filter: { mangaId: { in: $mangaIds } }
condition: { isDownloaded: $isDownloaded, isRead: $isRead, isBookmarked: $isBookmarked }
) {
nodes {
id
isDownloaded
isRead
isBookmarked
}
}
}
`;

View File

@@ -162,6 +162,14 @@ import {
ResetWebuiUpdateStatusMutationVariables,
GetDownloadStatusQuery,
GetDownloadStatusQueryVariables,
GetMangasChapterIdsWithStateQuery,
GetMangasChapterIdsWithStateQueryVariables,
ChapterConditionInput,
UpdateMangasMutation,
UpdateMangasMutationVariables,
UpdateMangasCategoriesMutation,
UpdateMangasCategoriesMutationVariables,
UpdateMangaCategoriesPatchInput,
} from '@/lib/graphql/generated/graphql.ts';
import { GET_GLOBAL_METADATAS } from '@/lib/graphql/queries/GlobalMetadataQuery.ts';
import { SET_GLOBAL_METADATA } from '@/lib/graphql/mutations/GlobalMetadataMutation.ts';
@@ -178,6 +186,8 @@ import {
SET_MANGA_METADATA,
UPDATE_MANGA,
UPDATE_MANGA_CATEGORIES,
UPDATE_MANGAS,
UPDATE_MANGAS_CATEGORIES,
} from '@/lib/graphql/mutations/MangaMutation.ts';
import { GET_MANGA, GET_MANGAS } from '@/lib/graphql/queries/MangaQuery.ts';
import { GET_CATEGORIES, GET_CATEGORY_MANGAS } from '@/lib/graphql/queries/CategoryQuery.ts';
@@ -195,7 +205,7 @@ import {
START_DOWNLOADER,
STOP_DOWNLOADER,
} from '@/lib/graphql/mutations/DownloaderMutation.ts';
import { GET_CHAPTERS } from '@/lib/graphql/queries/ChapterQuery.ts';
import { GET_CHAPTERS, GET_MANGAS_CHAPTER_IDS_WITH_STATE } from '@/lib/graphql/queries/ChapterQuery.ts';
import {
GET_CHAPTER_PAGES_FETCH,
GET_MANGA_CHAPTERS_FETCH,
@@ -841,6 +851,12 @@ export class RequestManager {
}
}
public getGlobalMeta(
options?: QueryOptions<GetGlobalMetadatasQueryVariables, GetGlobalMetadatasQuery>,
): AbortabaleApolloQueryResponse<GetGlobalMetadatasQuery> {
return this.doRequest(GQLMethod.QUERY, GET_GLOBAL_METADATAS, {}, options);
}
public useGetGlobalMeta(
options?: QueryHookOptions<GetGlobalMetadatasQuery, GetGlobalMetadatasQueryVariables>,
): AbortableApolloUseQueryResponse<GetGlobalMetadatasQuery, GetGlobalMetadatasQueryVariables> {
@@ -1337,7 +1353,8 @@ export class RequestManager {
const wrappedMutate = (mutateOptions: Parameters<typeof mutate>[0]) =>
mutate({
onCompleted: () => {
this.graphQLClient.client.cache.evict({ fieldName: 'mangas' });
this.graphQLClient.client.cache.evict({ broadcast: true, fieldName: 'categories' });
this.graphQLClient.client.cache.evict({ broadcast: true, fieldName: 'mangas' });
},
...mutateOptions,
});
@@ -1345,6 +1362,26 @@ export class RequestManager {
return [wrappedMutate, result];
}
public updateMangasCategories(
mangaIds: number[],
patch: UpdateMangaCategoriesPatchInput,
options?: MutationOptions<UpdateMangasCategoriesMutation, UpdateMangasCategoriesMutationVariables>,
): AbortableApolloMutationResponse<UpdateMangasCategoriesMutation> {
const response = this.doRequest(
GQLMethod.MUTATION,
UPDATE_MANGAS_CATEGORIES,
{ input: { ids: mangaIds, patch } },
options,
);
response.response.then(() => {
this.graphQLClient.client.cache.evict({ broadcast: true, fieldName: 'categories' });
this.graphQLClient.client.cache.evict({ broadcast: true, fieldName: 'mangas' });
});
return response;
}
public updateManga(
id: number,
patch: UpdateMangaPatchInput,
@@ -1364,6 +1401,27 @@ export class RequestManager {
return result;
}
public updateMangas(
ids: number[],
patch: UpdateMangaPatchInput,
options?: MutationOptions<UpdateMangasMutation, UpdateMangasMutationVariables>,
): AbortableApolloMutationResponse<UpdateMangasMutation> {
const result = this.doRequest<UpdateMangasMutation, UpdateMangasMutationVariables>(
GQLMethod.MUTATION,
UPDATE_MANGAS,
{ input: { ids, patch } },
options,
);
result.response.then(() => {
this.graphQLClient.client.cache.evict({ fieldName: 'categories' });
this.graphQLClient.client.cache.evict({ fieldName: 'category' });
this.graphQLClient.client.cache.evict({ fieldName: 'mangas' });
});
return result;
}
public setMangaMeta(
mangaId: number,
key: string,
@@ -1401,6 +1459,22 @@ export class RequestManager {
);
}
public getMangasChapterIdsWithState(
mangaIds: number[],
states: Pick<ChapterConditionInput, 'isRead' | 'isDownloaded' | 'isBookmarked'>,
options?: QueryOptions<GetMangasChapterIdsWithStateQueryVariables, GetMangasChapterIdsWithStateQuery>,
): AbortabaleApolloQueryResponse<GetMangasChapterIdsWithStateQuery> {
return this.doRequest(
GQLMethod.QUERY,
GET_MANGAS_CHAPTER_IDS_WITH_STATE,
{ mangaIds, ...states },
{
fetchPolicy: 'no-cache',
...options,
},
);
}
public getMangaChaptersFetch(
mangaId: number | string,
options?: MutationOptions<GetMangaChaptersFetchMutation, GetMangaChaptersFetchMutationVariables>,

View File

@@ -61,7 +61,6 @@ const typePolicies: StrictTypedTypePolicies = {
chapters: {
keyArgs: ['condition', 'filter', 'orderBy', 'orderByType'],
merge(existing, incoming) {
console.log('merge chapters', { ...existing }, { ...incoming });
if (existing == null) {
return incoming;
}

View File

@@ -7,7 +7,7 @@
*/
import { Chip, Tab, Tabs, styled, Box } from '@mui/material';
import React, { useContext, useEffect, useMemo } from 'react';
import React, { useContext, useEffect, useMemo, useState } from 'react';
import { useQueryParam, NumberParam } from 'use-query-params';
import { useTranslation } from 'react-i18next';
import { requestManager } from '@/lib/requests/RequestManager.ts';
@@ -20,6 +20,13 @@ import { AppbarSearch } from '@/components/util/AppbarSearch';
import { UpdateChecker } from '@/components/library/UpdateChecker';
import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import { NavBarContext } from '@/components/context/NavbarContext.tsx';
import { useSelectableCollection } from '@/components/collection/useSelectableCollection.ts';
import { TManga } from '@/typings.ts';
import { SelectableCollectionSelectMode } from '@/components/collection/SelectableCollectionSelectMode.tsx';
import { useGetVisibleLibraryMangas } from '@/components/library/useGetVisibleLibraryMangas.ts';
import { SelectionFAB } from '@/components/manga/SelectionFAB.tsx';
import { MangasSelectionFABActionItems } from '@/components/manga/MangasSelectionFABActionItems.tsx';
import { PARTIAL_MANGA_FIELDS } from '@/lib/graphql/Fragments.ts';
const StyledGridWrapper = styled(Box)(({ theme }) => ({
// TabsMenu height + TabsMenu bottom padding - grid item top padding
@@ -82,8 +89,36 @@ export function Library() {
data: categoryMangaResponse,
error: mangaError,
loading: mangaLoading,
} = requestManager.useGetCategoryMangas(activeTab?.id, { skip: !activeTab, nextFetchPolicy: 'cache-only' });
const mangas = categoryMangaResponse?.mangas.nodes ?? [];
} = requestManager.useGetCategoryMangas(activeTab?.id, { skip: !activeTab });
const categoryMangas = categoryMangaResponse?.mangas.nodes ?? [];
const { visibleMangas: mangas, showFilteredOutMessage } = useGetVisibleLibraryMangas(categoryMangas);
const [isSelectModeActive, setIsSelectModeActive] = useState(false);
const {
areNoItemsForKeySelected: areNoItemsSelected,
areAllItemsForKeySelected: areAllItemsSelected,
selectedItemIds,
handleSelectAll,
handleSelection,
} = useSelectableCollection<TManga['id'], string>(mangas.length, { currentKey: activeTab?.id.toString() });
const handleSelect = (id: number, selected: boolean) => {
setIsSelectModeActive(!!(selectedItemIds.length + (selected ? 1 : -1)));
handleSelection(id, selected);
};
const selectedMangas = useMemo(
() =>
selectedItemIds.map(
(id) =>
requestManager.graphQLClient.client.cache.readFragment<TManga>({
id: requestManager.graphQLClient.client.cache.identify({ __typename: 'MangaType', id }),
fragment: PARTIAL_MANGA_FIELDS,
fragmentName: 'PARTIAL_MANGA_FIELDS',
})!,
),
[selectedItemIds.length],
);
const { setTitle, setAction } = useContext(NavBarContext);
useEffect(() => {
@@ -91,22 +126,53 @@ export function Library() {
const navBarTitle = (
<TitleWithSizeTag>
{title}
{areCategoriesLoading || !options.showTabSize ? null : <TitleSizeTag label={librarySize} />}
{options.showTabSize && <TitleSizeTag label={librarySize} />}
</TitleWithSizeTag>
);
setTitle(navBarTitle, title);
setAction(
<>
<AppbarSearch />
<LibraryToolbarMenu />
<UpdateChecker />
{!isSelectModeActive && (
<>
<AppbarSearch />
<LibraryToolbarMenu />
<UpdateChecker />
</>
)}
<SelectableCollectionSelectMode
isActive={isSelectModeActive}
areAllItemsSelected={areAllItemsSelected}
areNoItemsSelected={areNoItemsSelected}
onSelectAll={(selectAll) =>
handleSelectAll(selectAll, [...new Set(mangas.map((manga) => manga.id))])
}
onModeChange={(checked) => {
setIsSelectModeActive(checked);
if (checked) {
handleSelectAll(true, [...new Set(mangas.map((manga) => manga.id))]);
} else {
tabs.forEach((tab) => handleSelectAll(false, [], tab.id.toString()));
}
}}
/>
</>,
);
return () => {
setTitle('');
setAction(null);
};
}, [t, librarySize, areCategoriesLoading, options]);
}, [
t,
librarySize,
areCategoriesLoading,
options,
isSelectModeActive,
areNoItemsSelected,
areAllItemsSelected,
selectedItemIds.length,
mangas.length,
]);
const handleTabChange = (newTab: number) => {
setTabSearchParam(newTab);
@@ -135,6 +201,10 @@ export function Library() {
mangas={mangas}
message={t('library.error.label.empty')}
isLoading={activeTab != null && mangaLoading}
selectedMangaIds={selectedItemIds}
isSelectModeActive={isSelectModeActive}
handleSelection={handleSelect}
showFilteredOutMessage={showFilteredOutMessage}
/>
);
}
@@ -143,49 +213,68 @@ export function Library() {
const scrollableTabs = window.innerWidth < tabs.length * 160;
return (
<StyledGridWrapper>
<TabsMenu
sx={{ borderBottom: 2, borderColor: 'divider' }}
value={activeTab.order}
onChange={(e, newTab) => handleTabChange(newTab)}
indicatorColor="primary"
textColor="primary"
centered={!scrollableTabs}
variant={scrollableTabs ? 'scrollable' : 'fullWidth'}
scrollButtons
allowScrollButtonsMobile
>
<>
<StyledGridWrapper>
<TabsMenu
sx={{ borderBottom: 2, borderColor: 'divider' }}
value={activeTab.order}
onChange={(e, newTab) => handleTabChange(newTab)}
indicatorColor="primary"
textColor="primary"
centered={!scrollableTabs}
variant={scrollableTabs ? 'scrollable' : 'fullWidth'}
scrollButtons
allowScrollButtonsMobile
>
{tabs.map((tab) => (
<Tab
sx={{ display: 'flex' }}
key={tab.id}
label={
<TitleWithSizeTag>
{tab.name}
{options.showTabSize ? <TitleSizeTag label={tab.mangas.totalCount} /> : null}
</TitleWithSizeTag>
}
value={tab.order}
/>
))}
</TabsMenu>
{tabs.map((tab) => (
<Tab
sx={{ display: 'flex' }}
key={tab.id}
label={
<TitleWithSizeTag>
{tab.name}
{options.showTabSize ? <TitleSizeTag label={tab.mangas.totalCount} /> : null}
</TitleWithSizeTag>
}
value={tab.order}
/>
<TabPanel key={tab.order} index={tab.order} currentIndex={activeTab.order}>
{tab === activeTab &&
(mangaError ? (
<EmptyView
message={t('manga.error.label.request_failure')}
messageExtra={mangaError.message ?? mangaError}
/>
) : (
<LibraryMangaGrid
mangas={mangas}
message={t('library.error.label.empty')}
isLoading={mangaLoading}
selectedMangaIds={selectedItemIds}
isSelectModeActive={isSelectModeActive}
handleSelection={handleSelect}
showFilteredOutMessage={showFilteredOutMessage}
/>
))}
</TabPanel>
))}
</TabsMenu>
{tabs.map((tab) => (
<TabPanel key={tab.order} index={tab.order} currentIndex={activeTab.order}>
{tab === activeTab &&
(mangaError ? (
<EmptyView
message={t('manga.error.label.request_failure')}
messageExtra={mangaError.message ?? mangaError}
/>
) : (
<LibraryMangaGrid
mangas={mangas}
message={t('library.error.label.empty')}
isLoading={mangaLoading}
/>
))}
</TabPanel>
))}
</StyledGridWrapper>
</StyledGridWrapper>
{isSelectModeActive && (
<SelectionFAB selectedItemsCount={selectedItemIds.length} title="manga.title">
{(handleClose) => (
<MangasSelectionFABActionItems
selectedMangas={selectedMangas}
handleClose={(selectionModeState) => {
handleClose();
setIsSelectModeActive(selectionModeState);
}}
/>
)}
</SelectionFAB>
)}
</>
);
}

View File

@@ -32,3 +32,14 @@ export const useMetadataServerSettings = (): {
return { metadata, settings, loading };
};
export const getMetadataServerSettings = async (): Promise<MetadataServerSettings> => {
const { data, error } = await requestManager.getGlobalMeta().response;
if (error) {
throw error;
}
const metadata = convertFromGqlMeta(data?.metas.nodes);
return getMetadataServerSettingsWithDefaultFallback(metadata);
};

124
yarn.lock
View File

@@ -487,6 +487,13 @@
dependencies:
regenerator-runtime "^0.14.0"
"@babel/runtime@^7.20.6", "@babel/runtime@^7.23.5":
version "7.23.6"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.6.tgz#c05e610dc228855dc92ef1b53d07389ed8ab521d"
integrity sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ==
dependencies:
regenerator-runtime "^0.14.0"
"@babel/template@^7.18.10", "@babel/template@^7.20.7", "@babel/template@^7.22.15":
version "7.22.15"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38"
@@ -1400,11 +1407,29 @@
clsx "^2.0.0"
prop-types "^15.8.1"
"@mui/base@5.0.0-beta.28":
version "5.0.0-beta.28"
resolved "https://registry.yarnpkg.com/@mui/base/-/base-5.0.0-beta.28.tgz#f072e55c0530f456ee5cb5cde2af788fdda3bf05"
integrity sha512-KIoSc5sUFceeCaZTq5MQBapFzhHqMo4kj+4azWaCAjorduhcRQtN+BCgVHmo+gvEjix74bUfxwTqGifnu2fNTg==
dependencies:
"@babel/runtime" "^7.23.5"
"@floating-ui/react-dom" "^2.0.4"
"@mui/types" "^7.2.11"
"@mui/utils" "^5.15.1"
"@popperjs/core" "^2.11.8"
clsx "^2.0.0"
prop-types "^15.8.1"
"@mui/core-downloads-tracker@^5.14.18":
version "5.14.18"
resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.14.18.tgz#f8b187dc89756fa5c0b7d15aea537a6f73f0c2d8"
integrity sha512-yFpF35fEVDV81nVktu0BE9qn2dD/chs7PsQhlyaV3EnTeZi9RZBuvoEfRym1/jmhJ2tcfeWXiRuHG942mQXJJQ==
"@mui/core-downloads-tracker@^5.15.1":
version "5.15.1"
resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.1.tgz#8aad47e2b198640244f05f6486a927ce362e814e"
integrity sha512-y/nUEsWHyBzaKYp9zLtqJKrLod/zMNEWpMj488FuQY9QTmqBiyUhI2uh7PVaLqLewXRtdmG6JV0b6T5exyuYRw==
"@mui/icons-material@^5.14.18":
version "5.14.18"
resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-5.14.18.tgz#9e92964cde8c7ba32cf50438a83403dc283f2328"
@@ -1412,6 +1437,24 @@
dependencies:
"@babel/runtime" "^7.23.2"
"@mui/material@^5.0.0":
version "5.15.1"
resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.15.1.tgz#5fc15c6eb9efe4b62b0c30b13bf5fa042bda71a1"
integrity sha512-WA5DVyvacxDakVyAhNqu/rRT28ppuuUFFw1bLpmRzrCJ4uw/zLTATcd4WB3YbB+7MdZNEGG/SJNWTDLEIyn3xQ==
dependencies:
"@babel/runtime" "^7.23.5"
"@mui/base" "5.0.0-beta.28"
"@mui/core-downloads-tracker" "^5.15.1"
"@mui/system" "^5.15.1"
"@mui/types" "^7.2.11"
"@mui/utils" "^5.15.1"
"@types/react-transition-group" "^4.4.10"
clsx "^2.0.0"
csstype "^3.1.2"
prop-types "^15.8.1"
react-is "^18.2.0"
react-transition-group "^4.4.5"
"@mui/material@^5.14.18":
version "5.14.18"
resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.14.18.tgz#d0a89be3e27afe90135d542ddbf160b3f34e869c"
@@ -1439,6 +1482,15 @@
"@mui/utils" "^5.14.18"
prop-types "^15.8.1"
"@mui/private-theming@^5.15.1":
version "5.15.1"
resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-5.15.1.tgz#58fd8da48295e105067fa7361734ee0b166d9cca"
integrity sha512-wTbzuy5KjSvCPE9UVJktWHJ0b/tD5biavY9wvF+OpYDLPpdXK52vc1hTDxSbdkHIFMkJExzrwO9GvpVAHZBnFQ==
dependencies:
"@babel/runtime" "^7.23.5"
"@mui/utils" "^5.15.1"
prop-types "^15.8.1"
"@mui/styled-engine@^5.14.18":
version "5.14.18"
resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.14.18.tgz#82d427bc975b85cecdbab2fd9353ed6c2df7eae1"
@@ -1449,6 +1501,16 @@
csstype "^3.1.2"
prop-types "^15.8.1"
"@mui/styled-engine@^5.15.1":
version "5.15.1"
resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.15.1.tgz#00f179e51afe252022bf356f72354968f9c5bf25"
integrity sha512-7WDZTJLqGexWDjqE9oAgjU8ak6hEtUw2yQU7SIYID5kLVO2Nj/Wi/KicbLsXnTsJNvSqePIlUIWTBSXwWJCPZw==
dependencies:
"@babel/runtime" "^7.23.5"
"@emotion/cache" "^11.11.0"
csstype "^3.1.2"
prop-types "^15.8.1"
"@mui/system@^5.14.18":
version "5.14.18"
resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.14.18.tgz#0f671e8f0a5e8e965b79235d77c50098f54195b5"
@@ -1463,6 +1525,25 @@
csstype "^3.1.2"
prop-types "^15.8.1"
"@mui/system@^5.15.1":
version "5.15.1"
resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.15.1.tgz#e2a79b5e188ca89a3e58aa4d27e3484edf9e24b0"
integrity sha512-LAnP0ls69rqW9eBgI29phIx/lppv+WDGI7b3EJN7VZIqw0RezA0GD7NRpV12BgEYJABEii6z5Q9B5tg7dsX0Iw==
dependencies:
"@babel/runtime" "^7.23.5"
"@mui/private-theming" "^5.15.1"
"@mui/styled-engine" "^5.15.1"
"@mui/types" "^7.2.11"
"@mui/utils" "^5.15.1"
clsx "^2.0.0"
csstype "^3.1.2"
prop-types "^15.8.1"
"@mui/types@^7.2.11":
version "7.2.11"
resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.11.tgz#36b99a88f8010dc716128e568dc05681a69dc7ae"
integrity sha512-KWe/QTEsFFlFSH+qRYf3zoFEj3z67s+qAuSnMMg+gFwbxG7P96Hm6g300inQL1Wy///gSRb8juX7Wafvp93m3w==
"@mui/types@^7.2.9":
version "7.2.9"
resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.9.tgz#730ee83a37af292a5973962f78ce5c95f31213a7"
@@ -1478,6 +1559,16 @@
prop-types "^15.8.1"
react-is "^18.2.0"
"@mui/utils@^5.15.1":
version "5.15.1"
resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.15.1.tgz#71d69dc8c0f13a1fd6aca20b53ec496636e6b854"
integrity sha512-V1/d0E3Bju5YdB59HJf2G0tnHrFEvWLN+f8hAXp9+JSNy/LC2zKyqUfPPahflR6qsI681P8G9r4mEZte/SrrYA==
dependencies:
"@babel/runtime" "^7.23.5"
"@types/prop-types" "^15.7.11"
prop-types "^15.8.1"
react-is "^18.2.0"
"@mui/x-date-pickers@^6.18.2":
version "6.18.2"
resolved "https://registry.yarnpkg.com/@mui/x-date-pickers/-/x-date-pickers-6.18.2.tgz#07f76c4a9ba022b8916607d9b3501c39160787c2"
@@ -1773,7 +1864,7 @@
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239"
integrity sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==
"@types/prop-types@*", "@types/prop-types@^15.7.10":
"@types/prop-types@*", "@types/prop-types@^15.7.10", "@types/prop-types@^15.7.11":
version "15.7.11"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.11.tgz#2596fb352ee96a1379c657734d4b913a613ad563"
integrity sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==
@@ -1802,6 +1893,13 @@
hoist-non-react-statics "^3.3.0"
redux "^4.0.0"
"@types/react-transition-group@^4.4.10":
version "4.4.10"
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.10.tgz#6ee71127bdab1f18f11ad8fb3322c6da27c327ac"
integrity sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==
dependencies:
"@types/react" "*"
"@types/react-transition-group@^4.4.8":
version "4.4.9"
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.9.tgz#12a1a1b5b8791067198149867b0823fbace31579"
@@ -2497,6 +2595,11 @@ chardet@^0.7.0:
resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
classnames@^2.2.6:
version "2.3.2"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924"
integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==
clean-stack@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b"
@@ -4356,6 +4459,16 @@ map-cache@^0.2.0:
resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf"
integrity sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==
material-ui-popup-state@^5.0.10:
version "5.0.10"
resolved "https://registry.yarnpkg.com/material-ui-popup-state/-/material-ui-popup-state-5.0.10.tgz#1c2d42cbe9f04f60fa6e4cd8ceb8ff5a456dfc62"
integrity sha512-gd0DI8skwCSdth/j/yndoIwNkS2eDusosTe5hyPZ3jbrMzDkbQBs+tBbwapQ9hLfgiVLwICd1mwyerUV9Y5Elw==
dependencies:
"@babel/runtime" "^7.20.6"
"@mui/material" "^5.0.0"
classnames "^2.2.6"
prop-types "^15.7.2"
memoize-one@^5.1.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
@@ -4877,6 +4990,13 @@ react-beautiful-dnd@^13.1.1:
redux "^4.0.4"
use-memo-one "^1.1.1"
react-device-detect@^2.2.3:
version "2.2.3"
resolved "https://registry.yarnpkg.com/react-device-detect/-/react-device-detect-2.2.3.tgz#97a7ae767cdd004e7c3578260f48cf70c036e7ca"
integrity sha512-buYY3qrCnQVlIFHrC5UcUoAj7iANs/+srdkwsnNjI7anr3Tt7UY6MqNxtMLlr0tMBied0O49UZVK8XKs3ZIiPw==
dependencies:
ua-parser-js "^1.0.33"
react-dom@^18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
@@ -5641,7 +5761,7 @@ typescript@^5.3.2:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.2.tgz#00d1c7c1c46928c5845c1ee8d0cc2791031d4c43"
integrity sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==
ua-parser-js@^1.0.35:
ua-parser-js@^1.0.33, ua-parser-js@^1.0.35:
version "1.0.37"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.37.tgz#b5dc7b163a5c1f0c510b08446aed4da92c46373f"
integrity sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==