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", "graphql-ws": "^5.14.2",
"i18next": "^23.7.6", "i18next": "^23.7.6",
"i18next-browser-languagedetector": "^7.2.0", "i18next-browser-languagedetector": "^7.2.0",
"material-ui-popup-state": "^5.0.10",
"react": "^18.2.0", "react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
"react-device-detect": "^2.2.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^13.5.0", "react-i18next": "^13.5.0",
"react-router-dom": "^6.20.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 { Link } from 'react-router-dom';
import { Avatar, Box, CardContent, Stack, styled, Tooltip } from '@mui/material'; import { Avatar, Box, CardContent, Stack, styled, Tooltip } from '@mui/material';
import { useTranslation } from 'react-i18next'; 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 { requestManager } from '@/lib/requests/RequestManager.ts';
import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext'; import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import { SpinnerImage } from '@/components/util/SpinnerImage'; 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 { 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')({ const BottomGradient = styled('div')({
position: 'absolute', position: 'absolute',
@@ -65,25 +70,24 @@ interface IProps {
manga: TPartialManga; manga: TPartialManga;
gridLayout?: GridLayout; gridLayout?: GridLayout;
inLibraryIndicator?: boolean; inLibraryIndicator?: boolean;
selected?: boolean | null;
handleSelection?: SelectableCollectionReturnType<TManga['id']>['handleSelection'];
} }
export const MangaCard = (props: IProps) => { export const MangaCard = (props: IProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { manga, gridLayout, inLibraryIndicator, selected, handleSelection } = props;
const { const {
manga: { id,
id, title,
title, thumbnailUrl: tmpThumbnailUrl,
thumbnailUrl: tmpThumbnailUrl, downloadCount,
downloadCount, unreadCount: unread,
unreadCount: unread, inLibrary,
inLibrary, lastReadChapter,
lastReadChapter, chapters,
chapters, } = manga;
},
gridLayout,
inLibraryIndicator,
} = props;
const thumbnailUrl = tmpThumbnailUrl ?? 'nonExistingMangaUrl'; const thumbnailUrl = tmpThumbnailUrl ?? 'nonExistingMangaUrl';
const { const {
options: { showContinueReadingButton, showUnreadBadge, showDownloadBadge }, options: { showContinueReadingButton, showUnreadBadge, showDownloadBadge },
@@ -96,208 +100,294 @@ export const MangaCard = (props: IProps) => {
if (gridLayout !== GridLayout.List) { if (gridLayout !== GridLayout.List) {
return ( return (
<Link to={mangaLinkTo} style={gridLayout === GridLayout.Comfortable ? { textDecoration: 'none' } : {}}> <PopupState variant="popover" popupId="manga-card-action-menu">
<Box {(popupState) => (
sx={{ <>
display: 'flex', <Link
flexDirection: 'column', onClick={(e) => {
}} if (selected === null) {
> return;
<Card }
sx={{
// force standard aspect ratio of manga covers e.preventDefault();
aspectRatio: '225/350', handleSelection?.(id, !selected);
display: 'flex',
}}
>
<CardActionArea
sx={{
position: 'relative',
height: '100%',
}} }}
to={mangaLinkTo}
style={gridLayout === GridLayout.Comfortable ? { textDecoration: 'none' } : {}}
> >
<BadgeContainer <Box
sx={{ sx={{
position: 'absolute', display: 'flex',
top: 5, flexDirection: 'column',
left: 5, 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 && ( <Card
<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"
sx={{ sx={{
position: 'absolute', // force standard aspect ratio of manga covers
bottom: 0, aspectRatio: '225/350',
width: '100%', display: 'flex',
margin: '0.5em 0',
padding: '0 0.5em',
gap: '0.5em',
}} }}
> >
{gridLayout !== GridLayout.Comfortable && ( <CardActionArea
<Tooltip title={title} placement="top"> sx={{
<GridMangaTitle 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={{ sx={{
color: 'white', position: 'absolute',
textShadow: '0px 0px 3px #000000', bottom: 0,
width: '100%',
margin: '0.5em 0',
padding: '0 0.5em',
gap: '0.5em',
}} }}
> >
{title} {gridLayout !== GridLayout.Comfortable && (
</GridMangaTitle> <Tooltip title={title} placement="top">
</Tooltip> <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 <ContinueReadingButton
showContinueReadingButton={showContinueReadingButton} showContinueReadingButton={showContinueReadingButton}
isLatestChapterRead={isLatestChapterRead} isLatestChapterRead={isLatestChapterRead}
nextChapterIndexToRead={nextChapterIndexToRead} nextChapterIndexToRead={nextChapterIndexToRead}
mangaLinkTo={mangaLinkTo} mangaLinkTo={mangaLinkTo}
/> />
<MangaOptionButton
popupState={popupState}
id={id}
selected={selected}
handleSelection={handleSelection}
asCheckbox
/>
</Stack> </Stack>
</> </CardContent>
</CardActionArea> </CardActionArea>
</Card> </Card>
{gridLayout === GridLayout.Comfortable && ( {!!handleSelection && popupState.isOpen && (
<Tooltip title={title} placement="top"> <MangaActionMenu
<GridMangaTitle {...bindMenu(popupState)}
sx={{ manga={manga as React.ComponentProps<typeof MangaActionMenu>['manga']}
position: 'relative', handleSelection={handleSelection}
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}
/> />
</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 Grid, { GridTypeMap } from '@mui/material/Grid';
import { Box, Typography } from '@mui/material'; import { Box, Typography } from '@mui/material';
import { GridItemProps, GridStateSnapshot, VirtuosoGrid } from 'react-virtuoso'; 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 { EmptyView } from '@/components/util/EmptyView';
import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder'; import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder';
import { MangaCard } from '@/components/MangaCard'; import { MangaCard } from '@/components/MangaCard';
import { GridLayout } from '@/components/context/LibraryOptionsContext'; import { GridLayout } from '@/components/context/LibraryOptionsContext';
import { useLocalStorage } from '@/util/useLocalStorage'; 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) => ( const GridContainer = React.forwardRef<HTMLDivElement, GridTypeMap['props']>(({ children, ...props }, ref) => (
<Grid {...props} ref={ref} container sx={{ paddingLeft: '5px', paddingRight: '13px' }}> <Grid {...props} ref={ref} container sx={{ paddingLeft: '5px', paddingRight: '13px' }}>
@@ -40,8 +42,22 @@ const GridItemContainerWithDimension = (
); );
}; };
const createMangaCard = (manga: TPartialManga, gridLayout?: GridLayout, inLibraryIndicator?: boolean) => ( const createMangaCard = (
<MangaCard key={manga.id} manga={manga} gridLayout={gridLayout} inLibraryIndicator={inLibraryIndicator} /> 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 = { type DefaultGridProps = {
@@ -50,9 +66,21 @@ type DefaultGridProps = {
inLibraryIndicator?: boolean; inLibraryIndicator?: boolean;
GridItemContainer: (props: GridTypeMap['props'] & Partial<GridItemProps>) => JSX.Element; GridItemContainer: (props: GridTypeMap['props'] & Partial<GridItemProps>) => JSX.Element;
gridLayout?: GridLayout; 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 <Grid
container container
spacing={1} spacing={1}
@@ -70,7 +98,14 @@ const HorizontalGrid = ({ isLoading, mangas, inLibraryIndicator, GridItemContain
) : ( ) : (
mangas.map((manga) => ( mangas.map((manga) => (
<GridItemContainer key={manga.id}> <GridItemContainer key={manga.id}>
{createMangaCard(manga, gridLayout, inLibraryIndicator)} {createMangaCard(
manga,
gridLayout,
inLibraryIndicator,
isSelectModeActive,
selectedMangaIds,
handleSelection,
)}
</GridItemContainer> </GridItemContainer>
)) ))
)} )}
@@ -85,6 +120,9 @@ const VerticalGrid = ({
gridLayout, gridLayout,
hasNextPage, hasNextPage,
loadMore, loadMore,
isSelectModeActive,
selectedMangaIds,
handleSelection,
}: DefaultGridProps & { }: DefaultGridProps & {
hasNextPage: boolean; hasNextPage: boolean;
loadMore: () => void; loadMore: () => void;
@@ -125,26 +163,38 @@ const VerticalGrid = ({
restoreStateFrom={snapshot} restoreStateFrom={snapshot}
stateChanged={persistGridState} stateChanged={persistGridState}
endReached={() => loadMore()} 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 */ {/* render div to prevent UI jumping around when showing/hiding loading placeholder */
/* eslint-disable-next-line no-nested-ternary */} /* 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 { export interface IMangaGridProps extends Omit<DefaultGridProps, 'GridItemContainer'> {
mangas: TPartialManga[];
isLoading: boolean;
message?: string; message?: string;
messageExtra?: JSX.Element; messageExtra?: JSX.Element;
hasNextPage: boolean; hasNextPage: boolean;
loadMore: () => void; loadMore: () => void;
gridLayout?: GridLayout;
horizontal?: boolean | undefined; horizontal?: boolean | undefined;
noFaces?: boolean | undefined; noFaces?: boolean | undefined;
inLibraryIndicator?: boolean;
} }
export const MangaGrid: React.FC<IMangaGridProps> = (props) => { export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
@@ -159,6 +209,9 @@ export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
horizontal, horizontal,
noFaces, noFaces,
inLibraryIndicator, inLibraryIndicator,
isSelectModeActive,
selectedMangaIds,
handleSelection,
} = props; } = props;
const [dimensions, setDimensions] = useState(document.documentElement.offsetWidth); const [dimensions, setDimensions] = useState(document.documentElement.offsetWidth);
@@ -221,6 +274,9 @@ export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
inLibraryIndicator={inLibraryIndicator} inLibraryIndicator={inLibraryIndicator}
GridItemContainer={GridItemContainer} GridItemContainer={GridItemContainer}
gridLayout={gridLayout} gridLayout={gridLayout}
isSelectModeActive={isSelectModeActive}
selectedMangaIds={selectedMangaIds}
handleSelection={handleSelection}
/> />
) : ( ) : (
<VerticalGrid <VerticalGrid
@@ -231,6 +287,9 @@ export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
hasNextPage={hasNextPage} hasNextPage={hasNextPage}
loadMore={loadMore} loadMore={loadMore}
gridLayout={gridLayout} gridLayout={gridLayout}
isSelectModeActive={isSelectModeActive}
selectedMangaIds={selectedMangaIds}
handleSelection={handleSelection}
/> />
)} )}
</div> </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'; import { useState } from 'react';
export const useSelectableCollection = <Id extends number | string>(totalCount: number) => { export type SelectableCollectionReturnType<Id extends number | string, Key extends string = string> = {
const [selectedItemIds, setSelectedItemIds] = useState<Id[]>([]); 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 areAllItemsSelected = selectedItemIds.length === totalCount;
const areNoItemsSelected = !selectedItemIds.length; 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; const deselect = !selected;
if (deselect) { if (deselect) {
setSelectedItemIds(selectedItemIds.filter((selectedItemId) => selectedItemId !== id)); setKeyToSelectedItemIds((prevState) => ({
...prevState,
[key]: prevState[key].filter((selectedItemId) => selectedItemId !== id),
}));
return; 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) { switch (selectAll) {
case true: case true:
setSelectedItemIds([...itemIds]); setKeyToSelectedItemIds((prevState) => ({
...prevState,
[key]: [...itemIds],
}));
break; break;
case false: case false:
setSelectedItemIds([]); setKeyToSelectedItemIds((prevState) => ({
...prevState,
[key]: [],
}));
break; break;
default: default:
break; 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/. * 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 { StringParam, useQueryParam } from 'use-query-params';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LibrarySortMode, NullAndUndefined, TManga } from '@/typings'; import { TManga } from '@/typings';
import { useSearchSettings } from '@/util/searchSettings';
import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext'; 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 => { interface LibraryMangaGridProps
switch (unread) { extends Required<Pick<IMangaGridProps, 'isSelectModeActive' | 'selectedMangaIds' | 'handleSelection'>> {
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 {
mangas: TManga[]; mangas: TManga[];
showFilteredOutMessage: boolean;
isLoading: boolean; isLoading: boolean;
message?: string; 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 { t } = useTranslation();
const [query] = useQueryParam('query', StringParam); const [query] = useQueryParam('query', StringParam);
const { options } = useLibraryOptionsContext(); const { options } = useLibraryOptionsContext();
const { unread, downloaded } = options; 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(() => { useEffect(() => {
window.scrollTo(0, 0); window.scrollTo(0, 0);
@@ -135,12 +42,15 @@ export const LibraryMangaGrid: React.FC<LibraryMangaGridProps> = ({ mangas, isLo
return ( return (
<MangaGrid <MangaGrid
mangas={sortedMangas} mangas={mangas}
isLoading={isLoading} isLoading={isLoading}
hasNextPage={false} hasNextPage={false}
loadMore={() => undefined} loadMore={() => undefined}
message={showFilteredOutMessage ? t('library.error.label.no_matches') : message} message={showFilteredOutMessage ? t('library.error.label.no_matches') : message}
gridLayout={options.gridLayout} 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 chapters = useMemo(() => chaptersData?.chapters.nodes ?? [], [chaptersData?.chapters.nodes]);
const { areNoItemsSelected, areAllItemsSelected, selectedItemIds, handleSelectAll, handleSelection } = const { areNoItemsSelected, areAllItemsSelected, selectedItemIds, handleSelectAll, handleSelection } =
useSelectableCollection(chapters.length); useSelectableCollection(chapters.length, { currentKey: 'default' });
const { settings: metadataServerSettings } = useMetadataServerSettings(); 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 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 React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { DEFAULT_FAB_STYLE } from '@/components/util/StyledFab'; import { DEFAULT_FAB_STYLE } from '@/components/util/StyledFab';
@@ -19,6 +19,16 @@ interface SelectionFABProps {
title: TranslationKey; 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 }) => { export const SelectionFAB: React.FC<SelectionFABProps> = ({ children, selectedItemsCount, title }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -27,15 +37,7 @@ export const SelectionFAB: React.FC<SelectionFABProps> = ({ children, selectedIt
const handleClose = () => setOpen(false); const handleClose = () => setOpen(false);
return ( return (
<Box <FabContainer ref={anchorEl}>
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}
>
<Fab variant="extended" color="primary" id="selectionMenuButton" onClick={() => setOpen(true)}> <Fab variant="extended" color="primary" id="selectionMenuButton" onClick={() => setOpen(true)}>
{`${selectedItemsCount} ${t(title, { count: selectedItemsCount })}`} {`${selectedItemsCount} ${t(title, { count: selectedItemsCount })}`}
<MoreHoriz sx={{ ml: 1 }} /> <MoreHoriz sx={{ ml: 1 }} />
@@ -53,6 +55,6 @@ export const SelectionFAB: React.FC<SelectionFABProps> = ({ children, selectedIt
> >
{children(handleClose)} {children(handleClose)}
</Menu> </Menu>
</Box> </FabContainer>
); );
}; };

View File

@@ -6,33 +6,80 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * 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 Button from '@mui/material/Button';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent'; import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions'; import DialogActions from '@mui/material/DialogActions';
import Dialog from '@mui/material/Dialog'; 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 FormGroup from '@mui/material/FormGroup';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { requestManager } from '@/lib/requests/RequestManager.ts'; 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; open: boolean;
setOpen: (value: boolean) => void; 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 { 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 { data } = requestManager.useGetCategories();
const categoriesData = data?.categories.nodes; const categoriesData = data?.categories.nodes;
const [triggerMutate] = requestManager.useUpdateMangaCategories();
const allCategories = useMemo(() => { const allCategories = useMemo(() => {
const cats = [...(categoriesData ?? [])]; // make copy const cats = [...(categoriesData ?? [])]; // make copy
@@ -42,29 +89,45 @@ export function CategorySelect(props: IProps) {
return cats; return cats;
}, [categoriesData]); }, [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 = () => { const handleCancel = () => {
setSelectionForKey('categoriesToAdd', mangaCategories);
setSelectionForKey('categoriesToRemove', []);
setOpen(false); setOpen(false);
}; };
const handleOk = () => { const handleOk = () => {
setOpen(false); setOpen(false);
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>, categoryId: number) => { const addToCategories = isSingleSelectionMode
const { checked } = event.target as HTMLInputElement; ? 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 const isUpdateRequired = !!addToCategories.length || !!removeFromCategories.length;
triggerMutate({ if (!isUpdateRequired) {
variables: { return;
input: { }
id: mangaId,
patch: { Mangas.performAction('change_categories', mangaIds, {
addToCategories: checked ? [categoryId] : [], changeCategoriesPatch: {
removeFromCategories: !checked ? [categoryId] : [], addToCategories,
}, removeFromCategories,
},
}, },
}); });
}; };
@@ -91,14 +154,25 @@ export function CategorySelect(props: IProps) {
</span> </span>
)} )}
{allCategories.map((category) => ( {allCategories.map((category) => (
<FormControlLabel <ThreeStateCheckboxInput
control={ checked={getCategoryCheckedState(
<Checkbox category.id,
checked={selectedIds.includes(category.id)} categoriesToAdd,
onChange={(e) => handleChange(e, category.id)} categoriesToRemove,
color="default" 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} label={category.name}
key={category.id} key={category.id}
/> />

View File

@@ -446,6 +446,34 @@
"title": "Library" "title": "Library"
}, },
"manga": { "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": { "button": {
"add_to_library": "Add To Library", "add_to_library": "Add To Library",
"in_library": "In Library" "in_library": "In Library"
@@ -464,7 +492,9 @@
"reload_from_source": "Reload data from source", "reload_from_source": "Reload data from source",
"status": "Status" "status": "Status"
}, },
"title": "Manga" "title": "Manga",
"title_one": "Manga",
"title_other": "Manga"
}, },
"reader": { "reader": {
"button": { "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 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; }>; 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, ResetWebuiUpdateStatusMutationVariables,
GetDownloadStatusQuery, GetDownloadStatusQuery,
GetDownloadStatusQueryVariables, GetDownloadStatusQueryVariables,
GetMangasChapterIdsWithStateQuery,
GetMangasChapterIdsWithStateQueryVariables,
ChapterConditionInput,
UpdateMangasMutation,
UpdateMangasMutationVariables,
UpdateMangasCategoriesMutation,
UpdateMangasCategoriesMutationVariables,
UpdateMangaCategoriesPatchInput,
} from '@/lib/graphql/generated/graphql.ts'; } from '@/lib/graphql/generated/graphql.ts';
import { GET_GLOBAL_METADATAS } from '@/lib/graphql/queries/GlobalMetadataQuery.ts'; import { GET_GLOBAL_METADATAS } from '@/lib/graphql/queries/GlobalMetadataQuery.ts';
import { SET_GLOBAL_METADATA } from '@/lib/graphql/mutations/GlobalMetadataMutation.ts'; import { SET_GLOBAL_METADATA } from '@/lib/graphql/mutations/GlobalMetadataMutation.ts';
@@ -178,6 +186,8 @@ import {
SET_MANGA_METADATA, SET_MANGA_METADATA,
UPDATE_MANGA, UPDATE_MANGA,
UPDATE_MANGA_CATEGORIES, UPDATE_MANGA_CATEGORIES,
UPDATE_MANGAS,
UPDATE_MANGAS_CATEGORIES,
} from '@/lib/graphql/mutations/MangaMutation.ts'; } from '@/lib/graphql/mutations/MangaMutation.ts';
import { GET_MANGA, GET_MANGAS } from '@/lib/graphql/queries/MangaQuery.ts'; import { GET_MANGA, GET_MANGAS } from '@/lib/graphql/queries/MangaQuery.ts';
import { GET_CATEGORIES, GET_CATEGORY_MANGAS } from '@/lib/graphql/queries/CategoryQuery.ts'; import { GET_CATEGORIES, GET_CATEGORY_MANGAS } from '@/lib/graphql/queries/CategoryQuery.ts';
@@ -195,7 +205,7 @@ import {
START_DOWNLOADER, START_DOWNLOADER,
STOP_DOWNLOADER, STOP_DOWNLOADER,
} from '@/lib/graphql/mutations/DownloaderMutation.ts'; } 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 { import {
GET_CHAPTER_PAGES_FETCH, GET_CHAPTER_PAGES_FETCH,
GET_MANGA_CHAPTERS_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( public useGetGlobalMeta(
options?: QueryHookOptions<GetGlobalMetadatasQuery, GetGlobalMetadatasQueryVariables>, options?: QueryHookOptions<GetGlobalMetadatasQuery, GetGlobalMetadatasQueryVariables>,
): AbortableApolloUseQueryResponse<GetGlobalMetadatasQuery, GetGlobalMetadatasQueryVariables> { ): AbortableApolloUseQueryResponse<GetGlobalMetadatasQuery, GetGlobalMetadatasQueryVariables> {
@@ -1337,7 +1353,8 @@ export class RequestManager {
const wrappedMutate = (mutateOptions: Parameters<typeof mutate>[0]) => const wrappedMutate = (mutateOptions: Parameters<typeof mutate>[0]) =>
mutate({ mutate({
onCompleted: () => { 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, ...mutateOptions,
}); });
@@ -1345,6 +1362,26 @@ export class RequestManager {
return [wrappedMutate, result]; 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( public updateManga(
id: number, id: number,
patch: UpdateMangaPatchInput, patch: UpdateMangaPatchInput,
@@ -1364,6 +1401,27 @@ export class RequestManager {
return result; 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( public setMangaMeta(
mangaId: number, mangaId: number,
key: string, 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( public getMangaChaptersFetch(
mangaId: number | string, mangaId: number | string,
options?: MutationOptions<GetMangaChaptersFetchMutation, GetMangaChaptersFetchMutationVariables>, options?: MutationOptions<GetMangaChaptersFetchMutation, GetMangaChaptersFetchMutationVariables>,

View File

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

View File

@@ -7,7 +7,7 @@
*/ */
import { Chip, Tab, Tabs, styled, Box } from '@mui/material'; 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 { useQueryParam, NumberParam } from 'use-query-params';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { requestManager } from '@/lib/requests/RequestManager.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts';
@@ -20,6 +20,13 @@ import { AppbarSearch } from '@/components/util/AppbarSearch';
import { UpdateChecker } from '@/components/library/UpdateChecker'; import { UpdateChecker } from '@/components/library/UpdateChecker';
import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext'; import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import { NavBarContext } from '@/components/context/NavbarContext.tsx'; 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 }) => ({ const StyledGridWrapper = styled(Box)(({ theme }) => ({
// TabsMenu height + TabsMenu bottom padding - grid item top padding // TabsMenu height + TabsMenu bottom padding - grid item top padding
@@ -82,8 +89,36 @@ export function Library() {
data: categoryMangaResponse, data: categoryMangaResponse,
error: mangaError, error: mangaError,
loading: mangaLoading, loading: mangaLoading,
} = requestManager.useGetCategoryMangas(activeTab?.id, { skip: !activeTab, nextFetchPolicy: 'cache-only' }); } = requestManager.useGetCategoryMangas(activeTab?.id, { skip: !activeTab });
const mangas = categoryMangaResponse?.mangas.nodes ?? []; 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); const { setTitle, setAction } = useContext(NavBarContext);
useEffect(() => { useEffect(() => {
@@ -91,22 +126,53 @@ export function Library() {
const navBarTitle = ( const navBarTitle = (
<TitleWithSizeTag> <TitleWithSizeTag>
{title} {title}
{areCategoriesLoading || !options.showTabSize ? null : <TitleSizeTag label={librarySize} />} {options.showTabSize && <TitleSizeTag label={librarySize} />}
</TitleWithSizeTag> </TitleWithSizeTag>
); );
setTitle(navBarTitle, title); setTitle(navBarTitle, title);
setAction( setAction(
<> <>
<AppbarSearch /> {!isSelectModeActive && (
<LibraryToolbarMenu /> <>
<UpdateChecker /> <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 () => { return () => {
setTitle(''); setTitle('');
setAction(null); setAction(null);
}; };
}, [t, librarySize, areCategoriesLoading, options]); }, [
t,
librarySize,
areCategoriesLoading,
options,
isSelectModeActive,
areNoItemsSelected,
areAllItemsSelected,
selectedItemIds.length,
mangas.length,
]);
const handleTabChange = (newTab: number) => { const handleTabChange = (newTab: number) => {
setTabSearchParam(newTab); setTabSearchParam(newTab);
@@ -135,6 +201,10 @@ export function Library() {
mangas={mangas} mangas={mangas}
message={t('library.error.label.empty')} message={t('library.error.label.empty')}
isLoading={activeTab != null && mangaLoading} 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; const scrollableTabs = window.innerWidth < tabs.length * 160;
return ( return (
<StyledGridWrapper> <>
<TabsMenu <StyledGridWrapper>
sx={{ borderBottom: 2, borderColor: 'divider' }} <TabsMenu
value={activeTab.order} sx={{ borderBottom: 2, borderColor: 'divider' }}
onChange={(e, newTab) => handleTabChange(newTab)} value={activeTab.order}
indicatorColor="primary" onChange={(e, newTab) => handleTabChange(newTab)}
textColor="primary" indicatorColor="primary"
centered={!scrollableTabs} textColor="primary"
variant={scrollableTabs ? 'scrollable' : 'fullWidth'} centered={!scrollableTabs}
scrollButtons variant={scrollableTabs ? 'scrollable' : 'fullWidth'}
allowScrollButtonsMobile 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) => ( {tabs.map((tab) => (
<Tab <TabPanel key={tab.order} index={tab.order} currentIndex={activeTab.order}>
sx={{ display: 'flex' }} {tab === activeTab &&
key={tab.id} (mangaError ? (
label={ <EmptyView
<TitleWithSizeTag> message={t('manga.error.label.request_failure')}
{tab.name} messageExtra={mangaError.message ?? mangaError}
{options.showTabSize ? <TitleSizeTag label={tab.mangas.totalCount} /> : null} />
</TitleWithSizeTag> ) : (
} <LibraryMangaGrid
value={tab.order} mangas={mangas}
/> message={t('library.error.label.empty')}
isLoading={mangaLoading}
selectedMangaIds={selectedItemIds}
isSelectModeActive={isSelectModeActive}
handleSelection={handleSelect}
showFilteredOutMessage={showFilteredOutMessage}
/>
))}
</TabPanel>
))} ))}
</TabsMenu> </StyledGridWrapper>
{tabs.map((tab) => ( {isSelectModeActive && (
<TabPanel key={tab.order} index={tab.order} currentIndex={activeTab.order}> <SelectionFAB selectedItemsCount={selectedItemIds.length} title="manga.title">
{tab === activeTab && {(handleClose) => (
(mangaError ? ( <MangasSelectionFABActionItems
<EmptyView selectedMangas={selectedMangas}
message={t('manga.error.label.request_failure')} handleClose={(selectionModeState) => {
messageExtra={mangaError.message ?? mangaError} handleClose();
/> setIsSelectModeActive(selectionModeState);
) : ( }}
<LibraryMangaGrid />
mangas={mangas} )}
message={t('library.error.label.empty')} </SelectionFAB>
isLoading={mangaLoading} )}
/> </>
))}
</TabPanel>
))}
</StyledGridWrapper>
); );
} }

View File

@@ -32,3 +32,14 @@ export const useMetadataServerSettings = (): {
return { metadata, settings, loading }; 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: dependencies:
regenerator-runtime "^0.14.0" 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": "@babel/template@^7.18.10", "@babel/template@^7.20.7", "@babel/template@^7.22.15":
version "7.22.15" version "7.22.15"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38"
@@ -1400,11 +1407,29 @@
clsx "^2.0.0" clsx "^2.0.0"
prop-types "^15.8.1" 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": "@mui/core-downloads-tracker@^5.14.18":
version "5.14.18" version "5.14.18"
resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.14.18.tgz#f8b187dc89756fa5c0b7d15aea537a6f73f0c2d8" resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.14.18.tgz#f8b187dc89756fa5c0b7d15aea537a6f73f0c2d8"
integrity sha512-yFpF35fEVDV81nVktu0BE9qn2dD/chs7PsQhlyaV3EnTeZi9RZBuvoEfRym1/jmhJ2tcfeWXiRuHG942mQXJJQ== 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": "@mui/icons-material@^5.14.18":
version "5.14.18" version "5.14.18"
resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-5.14.18.tgz#9e92964cde8c7ba32cf50438a83403dc283f2328" resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-5.14.18.tgz#9e92964cde8c7ba32cf50438a83403dc283f2328"
@@ -1412,6 +1437,24 @@
dependencies: dependencies:
"@babel/runtime" "^7.23.2" "@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": "@mui/material@^5.14.18":
version "5.14.18" version "5.14.18"
resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.14.18.tgz#d0a89be3e27afe90135d542ddbf160b3f34e869c" resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.14.18.tgz#d0a89be3e27afe90135d542ddbf160b3f34e869c"
@@ -1439,6 +1482,15 @@
"@mui/utils" "^5.14.18" "@mui/utils" "^5.14.18"
prop-types "^15.8.1" 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": "@mui/styled-engine@^5.14.18":
version "5.14.18" version "5.14.18"
resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.14.18.tgz#82d427bc975b85cecdbab2fd9353ed6c2df7eae1" resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.14.18.tgz#82d427bc975b85cecdbab2fd9353ed6c2df7eae1"
@@ -1449,6 +1501,16 @@
csstype "^3.1.2" csstype "^3.1.2"
prop-types "^15.8.1" 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": "@mui/system@^5.14.18":
version "5.14.18" version "5.14.18"
resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.14.18.tgz#0f671e8f0a5e8e965b79235d77c50098f54195b5" resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.14.18.tgz#0f671e8f0a5e8e965b79235d77c50098f54195b5"
@@ -1463,6 +1525,25 @@
csstype "^3.1.2" csstype "^3.1.2"
prop-types "^15.8.1" 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": "@mui/types@^7.2.9":
version "7.2.9" version "7.2.9"
resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.9.tgz#730ee83a37af292a5973962f78ce5c95f31213a7" resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.9.tgz#730ee83a37af292a5973962f78ce5c95f31213a7"
@@ -1478,6 +1559,16 @@
prop-types "^15.8.1" prop-types "^15.8.1"
react-is "^18.2.0" 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": "@mui/x-date-pickers@^6.18.2":
version "6.18.2" version "6.18.2"
resolved "https://registry.yarnpkg.com/@mui/x-date-pickers/-/x-date-pickers-6.18.2.tgz#07f76c4a9ba022b8916607d9b3501c39160787c2" 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" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239"
integrity sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw== 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" version "15.7.11"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.11.tgz#2596fb352ee96a1379c657734d4b913a613ad563" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.11.tgz#2596fb352ee96a1379c657734d4b913a613ad563"
integrity sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng== integrity sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==
@@ -1802,6 +1893,13 @@
hoist-non-react-statics "^3.3.0" hoist-non-react-statics "^3.3.0"
redux "^4.0.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": "@types/react-transition-group@^4.4.8":
version "4.4.9" version "4.4.9"
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.9.tgz#12a1a1b5b8791067198149867b0823fbace31579" 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" resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== 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: clean-stack@^2.0.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" 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" resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf"
integrity sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg== 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: memoize-one@^5.1.1:
version "5.2.1" version "5.2.1"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" 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" redux "^4.0.4"
use-memo-one "^1.1.1" 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: react-dom@^18.2.0:
version "18.2.0" version "18.2.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" 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" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.2.tgz#00d1c7c1c46928c5845c1ee8d0cc2791031d4c43"
integrity sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ== 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" version "1.0.37"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.37.tgz#b5dc7b163a5c1f0c510b08446aed4da92c46373f" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.37.tgz#b5dc7b163a5c1f0c510b08446aed4da92c46373f"
integrity sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ== integrity sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==