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:
@@ -45,8 +45,10 @@
|
||||
"graphql-ws": "^5.14.2",
|
||||
"i18next": "^23.7.6",
|
||||
"i18next-browser-languagedetector": "^7.2.0",
|
||||
"material-ui-popup-state": "^5.0.10",
|
||||
"react": "^18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^13.5.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
|
||||
@@ -12,11 +12,16 @@ import Typography from '@mui/material/Typography';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Avatar, Box, CardContent, Stack, styled, Tooltip } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import React from 'react';
|
||||
import PopupState, { bindMenu } from 'material-ui-popup-state';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
|
||||
import { SpinnerImage } from '@/components/util/SpinnerImage';
|
||||
import { TPartialManga } from '@/typings.ts';
|
||||
import { TManga, TPartialManga } from '@/typings.ts';
|
||||
import { ContinueReadingButton } from '@/components/manga/ContinueReadingButton.tsx';
|
||||
import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts';
|
||||
import { MangaOptionButton } from '@/components/manga/MangaOptionButton.tsx';
|
||||
import { MangaActionMenu } from '@/components/manga/MangaActionMenu.tsx';
|
||||
|
||||
const BottomGradient = styled('div')({
|
||||
position: 'absolute',
|
||||
@@ -65,25 +70,24 @@ interface IProps {
|
||||
manga: TPartialManga;
|
||||
gridLayout?: GridLayout;
|
||||
inLibraryIndicator?: boolean;
|
||||
selected?: boolean | null;
|
||||
handleSelection?: SelectableCollectionReturnType<TManga['id']>['handleSelection'];
|
||||
}
|
||||
|
||||
export const MangaCard = (props: IProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { manga, gridLayout, inLibraryIndicator, selected, handleSelection } = props;
|
||||
const {
|
||||
manga: {
|
||||
id,
|
||||
title,
|
||||
thumbnailUrl: tmpThumbnailUrl,
|
||||
downloadCount,
|
||||
unreadCount: unread,
|
||||
inLibrary,
|
||||
lastReadChapter,
|
||||
chapters,
|
||||
},
|
||||
gridLayout,
|
||||
inLibraryIndicator,
|
||||
} = props;
|
||||
id,
|
||||
title,
|
||||
thumbnailUrl: tmpThumbnailUrl,
|
||||
downloadCount,
|
||||
unreadCount: unread,
|
||||
inLibrary,
|
||||
lastReadChapter,
|
||||
chapters,
|
||||
} = manga;
|
||||
const thumbnailUrl = tmpThumbnailUrl ?? 'nonExistingMangaUrl';
|
||||
const {
|
||||
options: { showContinueReadingButton, showUnreadBadge, showDownloadBadge },
|
||||
@@ -96,208 +100,294 @@ export const MangaCard = (props: IProps) => {
|
||||
|
||||
if (gridLayout !== GridLayout.List) {
|
||||
return (
|
||||
<Link to={mangaLinkTo} style={gridLayout === GridLayout.Comfortable ? { textDecoration: 'none' } : {}}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
// force standard aspect ratio of manga covers
|
||||
aspectRatio: '225/350',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<CardActionArea
|
||||
sx={{
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
<PopupState variant="popover" popupId="manga-card-action-menu">
|
||||
{(popupState) => (
|
||||
<>
|
||||
<Link
|
||||
onClick={(e) => {
|
||||
if (selected === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
handleSelection?.(id, !selected);
|
||||
}}
|
||||
to={mangaLinkTo}
|
||||
style={gridLayout === GridLayout.Comfortable ? { textDecoration: 'none' } : {}}
|
||||
>
|
||||
<BadgeContainer
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 5,
|
||||
left: 5,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
margin: '2px',
|
||||
outline: selected ? '4px solid' : undefined,
|
||||
borderRadius: selected ? '1px' : undefined,
|
||||
outlineColor: (theme) => theme.palette.primary.main,
|
||||
backgroundColor: (theme) => (selected ? theme.palette.primary.main : undefined),
|
||||
'@media (hover: hover) and (pointer: fine)': {
|
||||
'&:hover .manga-option-button': {
|
||||
visibility: 'visible',
|
||||
pointerEvents: 'all',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{inLibraryIndicator && inLibrary && (
|
||||
<Typography sx={{ backgroundColor: 'primary.dark', zIndex: '1' }}>
|
||||
{t('manga.button.in_library')}
|
||||
</Typography>
|
||||
)}
|
||||
{showUnreadBadge && (unread ?? 0) > 0 && (
|
||||
<Typography sx={{ backgroundColor: 'primary.dark' }}>{unread}</Typography>
|
||||
)}
|
||||
{showDownloadBadge && (downloadCount ?? 0) > 0 && (
|
||||
<Typography
|
||||
sx={{
|
||||
backgroundColor: 'success.dark',
|
||||
}}
|
||||
>
|
||||
{downloadCount}
|
||||
</Typography>
|
||||
)}
|
||||
</BadgeContainer>
|
||||
<SpinnerImage
|
||||
alt={title}
|
||||
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
|
||||
imgStyle={
|
||||
inLibraryIndicator && inLibrary
|
||||
? {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
objectFit: 'cover',
|
||||
filter: 'brightness(0.4)',
|
||||
}
|
||||
: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
objectFit: 'cover',
|
||||
}
|
||||
}
|
||||
spinnerStyle={{
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<BottomGradient />
|
||||
<BottomGradientDoubledDown />
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent={gridLayout !== GridLayout.Comfortable ? 'space-between' : 'end'}
|
||||
alignItems="end"
|
||||
<Card
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
margin: '0.5em 0',
|
||||
padding: '0 0.5em',
|
||||
gap: '0.5em',
|
||||
// force standard aspect ratio of manga covers
|
||||
aspectRatio: '225/350',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{gridLayout !== GridLayout.Comfortable && (
|
||||
<Tooltip title={title} placement="top">
|
||||
<GridMangaTitle
|
||||
<CardActionArea
|
||||
sx={{
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
alignItems="start"
|
||||
justifyContent="space-between"
|
||||
direction="row"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 5,
|
||||
left: 5,
|
||||
right: 5,
|
||||
}}
|
||||
>
|
||||
<BadgeContainer>
|
||||
{inLibraryIndicator && inLibrary && (
|
||||
<Typography sx={{ backgroundColor: 'primary.dark', zIndex: '1' }}>
|
||||
{t('manga.button.in_library')}
|
||||
</Typography>
|
||||
)}
|
||||
{showUnreadBadge && (unread ?? 0) > 0 && (
|
||||
<Typography sx={{ backgroundColor: 'primary.dark' }}>
|
||||
{unread}
|
||||
</Typography>
|
||||
)}
|
||||
{showDownloadBadge && (downloadCount ?? 0) > 0 && (
|
||||
<Typography
|
||||
sx={{
|
||||
backgroundColor: 'success.dark',
|
||||
}}
|
||||
>
|
||||
{downloadCount}
|
||||
</Typography>
|
||||
)}
|
||||
</BadgeContainer>
|
||||
<MangaOptionButton
|
||||
popupState={popupState}
|
||||
id={id}
|
||||
selected={selected}
|
||||
handleSelection={handleSelection}
|
||||
/>
|
||||
</Stack>
|
||||
<SpinnerImage
|
||||
alt={title}
|
||||
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
|
||||
imgStyle={
|
||||
inLibraryIndicator && inLibrary
|
||||
? {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
objectFit: 'cover',
|
||||
filter: 'brightness(0.4)',
|
||||
}
|
||||
: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
objectFit: 'cover',
|
||||
}
|
||||
}
|
||||
spinnerStyle={{
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{gridLayout !== GridLayout.Comfortable && (
|
||||
<>
|
||||
<BottomGradient />
|
||||
<BottomGradientDoubledDown />
|
||||
</>
|
||||
)}
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent={
|
||||
gridLayout !== GridLayout.Comfortable ? 'space-between' : 'end'
|
||||
}
|
||||
alignItems="end"
|
||||
sx={{
|
||||
color: 'white',
|
||||
textShadow: '0px 0px 3px #000000',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
margin: '0.5em 0',
|
||||
padding: '0 0.5em',
|
||||
gap: '0.5em',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</GridMangaTitle>
|
||||
</Tooltip>
|
||||
)}
|
||||
{gridLayout !== GridLayout.Comfortable && (
|
||||
<Tooltip title={title} placement="top">
|
||||
<GridMangaTitle
|
||||
sx={{
|
||||
color: 'white',
|
||||
textShadow: '0px 0px 3px #000000',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</GridMangaTitle>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ContinueReadingButton
|
||||
showContinueReadingButton={showContinueReadingButton}
|
||||
isLatestChapterRead={isLatestChapterRead}
|
||||
nextChapterIndexToRead={nextChapterIndexToRead}
|
||||
mangaLinkTo={mangaLinkTo}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
{gridLayout === GridLayout.Comfortable && (
|
||||
<Tooltip title={title} placement="top">
|
||||
<GridMangaTitle
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
bottom: 0,
|
||||
margin: '0.5em 0',
|
||||
padding: '0 0.5em',
|
||||
color: 'text.primary',
|
||||
height: '3rem',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</GridMangaTitle>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Link>
|
||||
{!!handleSelection && popupState.isOpen && (
|
||||
<MangaActionMenu
|
||||
{...bindMenu(popupState)}
|
||||
manga={manga as React.ComponentProps<typeof MangaActionMenu>['manga']}
|
||||
handleSelection={handleSelection}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PopupState>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PopupState variant="popover" popupId="manga-card-action-menu">
|
||||
{(popupState) => (
|
||||
<>
|
||||
<Card>
|
||||
<CardActionArea
|
||||
component={Link}
|
||||
to={mangaLinkTo}
|
||||
onClick={(e) => {
|
||||
if (selected === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
handleSelection?.(id, !selected);
|
||||
}}
|
||||
>
|
||||
<CardContent
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 2,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
variant="rounded"
|
||||
sx={
|
||||
inLibraryIndicator && inLibrary
|
||||
? {
|
||||
width: 56,
|
||||
height: 56,
|
||||
flex: '0 0 auto',
|
||||
marginRight: 2,
|
||||
imageRendering: 'pixelated',
|
||||
filter: 'brightness(0.4)',
|
||||
}
|
||||
: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
flex: '0 0 auto',
|
||||
marginRight: 2,
|
||||
imageRendering: 'pixelated',
|
||||
}
|
||||
}
|
||||
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexGrow: 1,
|
||||
width: 'min-content',
|
||||
}}
|
||||
>
|
||||
<Tooltip title={title} placement="top">
|
||||
<MangaTitle variant="h5">{title}</MangaTitle>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Stack direction="row" alignItems="center" gap="5px">
|
||||
<BadgeContainer>
|
||||
{inLibraryIndicator && inLibrary && (
|
||||
<Typography sx={{ backgroundColor: 'primary.dark' }}>
|
||||
{t('manga.button.in_library')}
|
||||
</Typography>
|
||||
)}
|
||||
{showUnreadBadge && unread! > 0 && (
|
||||
<Typography sx={{ backgroundColor: 'primary.dark' }}>{unread}</Typography>
|
||||
)}
|
||||
{showDownloadBadge && downloadCount! > 0 && (
|
||||
<Typography
|
||||
sx={{
|
||||
backgroundColor: 'success.dark',
|
||||
}}
|
||||
>
|
||||
{downloadCount}
|
||||
</Typography>
|
||||
)}
|
||||
</BadgeContainer>
|
||||
<ContinueReadingButton
|
||||
showContinueReadingButton={showContinueReadingButton}
|
||||
isLatestChapterRead={isLatestChapterRead}
|
||||
nextChapterIndexToRead={nextChapterIndexToRead}
|
||||
mangaLinkTo={mangaLinkTo}
|
||||
/>
|
||||
<MangaOptionButton
|
||||
popupState={popupState}
|
||||
id={id}
|
||||
selected={selected}
|
||||
handleSelection={handleSelection}
|
||||
asCheckbox
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
{gridLayout === GridLayout.Comfortable && (
|
||||
<Tooltip title={title} placement="top">
|
||||
<GridMangaTitle
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
bottom: 0,
|
||||
margin: '0.5em 0',
|
||||
padding: '0 0.5em',
|
||||
color: 'text.primary',
|
||||
height: '3rem',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</GridMangaTitle>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardActionArea component={Link} to={mangaLinkTo}>
|
||||
<CardContent
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 2,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
variant="rounded"
|
||||
sx={
|
||||
inLibraryIndicator && inLibrary
|
||||
? {
|
||||
width: 56,
|
||||
height: 56,
|
||||
flex: '0 0 auto',
|
||||
marginRight: 2,
|
||||
imageRendering: 'pixelated',
|
||||
filter: 'brightness(0.4)',
|
||||
}
|
||||
: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
flex: '0 0 auto',
|
||||
marginRight: 2,
|
||||
imageRendering: 'pixelated',
|
||||
}
|
||||
}
|
||||
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexGrow: 1,
|
||||
width: 'min-content',
|
||||
}}
|
||||
>
|
||||
<Tooltip title={title} placement="top">
|
||||
<MangaTitle variant="h5">{title}</MangaTitle>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Stack direction="row" alignItems="center" gap="5px">
|
||||
<BadgeContainer>
|
||||
{inLibraryIndicator && inLibrary && (
|
||||
<Typography sx={{ backgroundColor: 'primary.dark' }}>
|
||||
{t('manga.button.in_library')}
|
||||
</Typography>
|
||||
)}
|
||||
{showUnreadBadge && unread! > 0 && (
|
||||
<Typography sx={{ backgroundColor: 'primary.dark' }}>{unread}</Typography>
|
||||
)}
|
||||
{showDownloadBadge && downloadCount! > 0 && (
|
||||
<Typography
|
||||
sx={{
|
||||
backgroundColor: 'success.dark',
|
||||
}}
|
||||
>
|
||||
{downloadCount}
|
||||
</Typography>
|
||||
)}
|
||||
</BadgeContainer>
|
||||
<ContinueReadingButton
|
||||
showContinueReadingButton={showContinueReadingButton}
|
||||
isLatestChapterRead={isLatestChapterRead}
|
||||
nextChapterIndexToRead={nextChapterIndexToRead}
|
||||
mangaLinkTo={mangaLinkTo}
|
||||
{!!handleSelection && popupState.isOpen && (
|
||||
<MangaActionMenu
|
||||
{...bindMenu(popupState)}
|
||||
manga={manga as React.ComponentProps<typeof MangaActionMenu>['manga']}
|
||||
handleSelection={handleSelection}
|
||||
/>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PopupState>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,13 +10,15 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 're
|
||||
import Grid, { GridTypeMap } from '@mui/material/Grid';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { GridItemProps, GridStateSnapshot, VirtuosoGrid } from 'react-virtuoso';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { EmptyView } from '@/components/util/EmptyView';
|
||||
import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder';
|
||||
import { MangaCard } from '@/components/MangaCard';
|
||||
import { GridLayout } from '@/components/context/LibraryOptionsContext';
|
||||
import { useLocalStorage } from '@/util/useLocalStorage';
|
||||
import { TPartialManga } from '@/typings.ts';
|
||||
import { TManga, TPartialManga } from '@/typings.ts';
|
||||
import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts';
|
||||
import { DEFAULT_FULL_FAB_HEIGHT } from '@/components/util/StyledFab.tsx';
|
||||
|
||||
const GridContainer = React.forwardRef<HTMLDivElement, GridTypeMap['props']>(({ children, ...props }, ref) => (
|
||||
<Grid {...props} ref={ref} container sx={{ paddingLeft: '5px', paddingRight: '13px' }}>
|
||||
@@ -40,8 +42,22 @@ const GridItemContainerWithDimension = (
|
||||
);
|
||||
};
|
||||
|
||||
const createMangaCard = (manga: TPartialManga, gridLayout?: GridLayout, inLibraryIndicator?: boolean) => (
|
||||
<MangaCard key={manga.id} manga={manga} gridLayout={gridLayout} inLibraryIndicator={inLibraryIndicator} />
|
||||
const createMangaCard = (
|
||||
manga: TPartialManga,
|
||||
gridLayout?: GridLayout,
|
||||
inLibraryIndicator?: boolean,
|
||||
isSelectModeActive: boolean = false,
|
||||
selectedMangaIds?: TManga['id'][],
|
||||
handleSelection?: DefaultGridProps['handleSelection'],
|
||||
) => (
|
||||
<MangaCard
|
||||
key={manga.id}
|
||||
manga={manga}
|
||||
gridLayout={gridLayout}
|
||||
inLibraryIndicator={inLibraryIndicator}
|
||||
selected={isSelectModeActive ? selectedMangaIds?.includes(manga.id) : null}
|
||||
handleSelection={handleSelection}
|
||||
/>
|
||||
);
|
||||
|
||||
type DefaultGridProps = {
|
||||
@@ -50,9 +66,21 @@ type DefaultGridProps = {
|
||||
inLibraryIndicator?: boolean;
|
||||
GridItemContainer: (props: GridTypeMap['props'] & Partial<GridItemProps>) => JSX.Element;
|
||||
gridLayout?: GridLayout;
|
||||
isSelectModeActive?: boolean;
|
||||
selectedMangaIds?: Required<TManga['id']>[];
|
||||
handleSelection?: SelectableCollectionReturnType<TManga['id']>['handleSelection'];
|
||||
};
|
||||
|
||||
const HorizontalGrid = ({ isLoading, mangas, inLibraryIndicator, GridItemContainer, gridLayout }: DefaultGridProps) => (
|
||||
const HorizontalGrid = ({
|
||||
isLoading,
|
||||
mangas,
|
||||
inLibraryIndicator,
|
||||
GridItemContainer,
|
||||
gridLayout,
|
||||
isSelectModeActive,
|
||||
selectedMangaIds,
|
||||
handleSelection,
|
||||
}: DefaultGridProps) => (
|
||||
<Grid
|
||||
container
|
||||
spacing={1}
|
||||
@@ -70,7 +98,14 @@ const HorizontalGrid = ({ isLoading, mangas, inLibraryIndicator, GridItemContain
|
||||
) : (
|
||||
mangas.map((manga) => (
|
||||
<GridItemContainer key={manga.id}>
|
||||
{createMangaCard(manga, gridLayout, inLibraryIndicator)}
|
||||
{createMangaCard(
|
||||
manga,
|
||||
gridLayout,
|
||||
inLibraryIndicator,
|
||||
isSelectModeActive,
|
||||
selectedMangaIds,
|
||||
handleSelection,
|
||||
)}
|
||||
</GridItemContainer>
|
||||
))
|
||||
)}
|
||||
@@ -85,6 +120,9 @@ const VerticalGrid = ({
|
||||
gridLayout,
|
||||
hasNextPage,
|
||||
loadMore,
|
||||
isSelectModeActive,
|
||||
selectedMangaIds,
|
||||
handleSelection,
|
||||
}: DefaultGridProps & {
|
||||
hasNextPage: boolean;
|
||||
loadMore: () => void;
|
||||
@@ -125,26 +163,38 @@ const VerticalGrid = ({
|
||||
restoreStateFrom={snapshot}
|
||||
stateChanged={persistGridState}
|
||||
endReached={() => loadMore()}
|
||||
itemContent={(index) => createMangaCard(mangas[index], gridLayout, inLibraryIndicator)}
|
||||
itemContent={(index) =>
|
||||
createMangaCard(
|
||||
mangas[index],
|
||||
gridLayout,
|
||||
inLibraryIndicator,
|
||||
isSelectModeActive,
|
||||
selectedMangaIds,
|
||||
handleSelection,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{/* render div to prevent UI jumping around when showing/hiding loading placeholder */
|
||||
/* eslint-disable-next-line no-nested-ternary */}
|
||||
{isLoading ? <LoadingPlaceholder /> : hasNextPage ? <div style={{ height: '75px' }} /> : null}
|
||||
{isSelectModeActive && gridLayout === GridLayout.List ? (
|
||||
<Box sx={{ paddingBottom: DEFAULT_FULL_FAB_HEIGHT }} />
|
||||
) : // eslint-disable-next-line no-nested-ternary
|
||||
isLoading ? (
|
||||
<LoadingPlaceholder />
|
||||
) : hasNextPage ? (
|
||||
<div style={{ height: '75px' }} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export interface IMangaGridProps {
|
||||
mangas: TPartialManga[];
|
||||
isLoading: boolean;
|
||||
export interface IMangaGridProps extends Omit<DefaultGridProps, 'GridItemContainer'> {
|
||||
message?: string;
|
||||
messageExtra?: JSX.Element;
|
||||
hasNextPage: boolean;
|
||||
loadMore: () => void;
|
||||
gridLayout?: GridLayout;
|
||||
horizontal?: boolean | undefined;
|
||||
noFaces?: boolean | undefined;
|
||||
inLibraryIndicator?: boolean;
|
||||
}
|
||||
|
||||
export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
|
||||
@@ -159,6 +209,9 @@ export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
|
||||
horizontal,
|
||||
noFaces,
|
||||
inLibraryIndicator,
|
||||
isSelectModeActive,
|
||||
selectedMangaIds,
|
||||
handleSelection,
|
||||
} = props;
|
||||
|
||||
const [dimensions, setDimensions] = useState(document.documentElement.offsetWidth);
|
||||
@@ -221,6 +274,9 @@ export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
|
||||
inLibraryIndicator={inLibraryIndicator}
|
||||
GridItemContainer={GridItemContainer}
|
||||
gridLayout={gridLayout}
|
||||
isSelectModeActive={isSelectModeActive}
|
||||
selectedMangaIds={selectedMangaIds}
|
||||
handleSelection={handleSelection}
|
||||
/>
|
||||
) : (
|
||||
<VerticalGrid
|
||||
@@ -231,6 +287,9 @@ export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
|
||||
hasNextPage={hasNextPage}
|
||||
loadMore={loadMore}
|
||||
gridLayout={gridLayout}
|
||||
isSelectModeActive={isSelectModeActive}
|
||||
selectedMangaIds={selectedMangaIds}
|
||||
handleSelection={handleSelection}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
49
src/components/collection/SelectableCollectionSelectMode.tsx
Normal file
49
src/components/collection/SelectableCollectionSelectMode.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -8,34 +8,92 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export const useSelectableCollection = <Id extends number | string>(totalCount: number) => {
|
||||
const [selectedItemIds, setSelectedItemIds] = useState<Id[]>([]);
|
||||
export type SelectableCollectionReturnType<Id extends number | string, Key extends string = string> = {
|
||||
selectedItemIds: Id[];
|
||||
keySelectedItemIds: Id[];
|
||||
areAllItemsSelected: boolean;
|
||||
areNoItemsSelected: boolean;
|
||||
areAllItemsForKeySelected: boolean;
|
||||
areNoItemsForKeySelected: boolean;
|
||||
handleSelection: (id: Id, selected: boolean, key?: Key) => void;
|
||||
handleSelectAll: (selectAll: boolean, itemIds: Id[], key?: Key) => void;
|
||||
setSelectionForKey: (key: Key, itemIds: Id[]) => void;
|
||||
getSelectionForKey: (key: Key) => Id[];
|
||||
};
|
||||
|
||||
export const useSelectableCollection = <Id extends number | string, Key extends string = 'default'>(
|
||||
totalCount: number,
|
||||
{
|
||||
keyCount = totalCount,
|
||||
currentKey,
|
||||
initialState = {} as Record<Key, Id[]>,
|
||||
}: {
|
||||
keyCount?: number;
|
||||
currentKey: Key;
|
||||
initialState?: Record<Key, Id[]>;
|
||||
},
|
||||
): SelectableCollectionReturnType<Id, Key> => {
|
||||
const [keyToSelectedItemIds, setKeyToSelectedItemIds] = useState<Record<string, Id[]>>(initialState);
|
||||
|
||||
const selectedItemIds = Object.values(keyToSelectedItemIds).flat();
|
||||
const areAllItemsSelected = selectedItemIds.length === totalCount;
|
||||
const areNoItemsSelected = !selectedItemIds.length;
|
||||
|
||||
const handleSelection = (id: Id, selected: boolean) => {
|
||||
const keySelectedItemIds = keyToSelectedItemIds[currentKey] ?? [];
|
||||
const areAllItemsForKeySelected = keySelectedItemIds.length === keyCount;
|
||||
const areNoItemsForKeySelected = keySelectedItemIds.length === 0;
|
||||
|
||||
const handleSelection = (id: Id, selected: boolean, key: Key = currentKey) => {
|
||||
const deselect = !selected;
|
||||
if (deselect) {
|
||||
setSelectedItemIds(selectedItemIds.filter((selectedItemId) => selectedItemId !== id));
|
||||
setKeyToSelectedItemIds((prevState) => ({
|
||||
...prevState,
|
||||
[key]: prevState[key].filter((selectedItemId) => selectedItemId !== id),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedItemIds([...new Set([...selectedItemIds, id])]);
|
||||
setKeyToSelectedItemIds((prevState) => ({
|
||||
...prevState,
|
||||
[key]: [...new Set([...(prevState[key] ?? []), id])],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSelectAll = (selectAll: boolean, itemIds: Id[]) => {
|
||||
const handleSelectAll = (selectAll: boolean, itemIds: Id[], key: Key = currentKey) => {
|
||||
switch (selectAll) {
|
||||
case true:
|
||||
setSelectedItemIds([...itemIds]);
|
||||
setKeyToSelectedItemIds((prevState) => ({
|
||||
...prevState,
|
||||
[key]: [...itemIds],
|
||||
}));
|
||||
break;
|
||||
case false:
|
||||
setSelectedItemIds([]);
|
||||
setKeyToSelectedItemIds((prevState) => ({
|
||||
...prevState,
|
||||
[key]: [],
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return { selectedItemIds, handleSelection, handleSelectAll, areAllItemsSelected, areNoItemsSelected };
|
||||
const setSelectionForKey = (key: Key, itemIds: Id[]) => {
|
||||
keyToSelectedItemIds[key] = itemIds;
|
||||
};
|
||||
|
||||
const getSelectionForKey = (key: Key) => keyToSelectedItemIds[key];
|
||||
|
||||
return {
|
||||
selectedItemIds,
|
||||
keySelectedItemIds,
|
||||
handleSelection,
|
||||
handleSelectAll,
|
||||
areAllItemsSelected,
|
||||
areNoItemsSelected,
|
||||
areAllItemsForKeySelected,
|
||||
areNoItemsForKeySelected,
|
||||
setSelectionForKey,
|
||||
getSelectionForKey,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,128 +6,35 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { StringParam, useQueryParam } from 'use-query-params';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LibrarySortMode, NullAndUndefined, TManga } from '@/typings';
|
||||
import { useSearchSettings } from '@/util/searchSettings';
|
||||
import { TManga } from '@/typings';
|
||||
import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
|
||||
import { MangaGrid } from '@/components/MangaGrid';
|
||||
import { IMangaGridProps, MangaGrid } from '@/components/MangaGrid';
|
||||
|
||||
const unreadFilter = (unread: NullAndUndefined<boolean>, { unreadCount }: TManga): boolean => {
|
||||
switch (unread) {
|
||||
case true:
|
||||
return !!unreadCount && unreadCount >= 1;
|
||||
case false:
|
||||
return unreadCount === 0;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadedFilter = (downloaded: NullAndUndefined<boolean>, { downloadCount }: TManga): boolean => {
|
||||
switch (downloaded) {
|
||||
case true:
|
||||
return !!downloadCount && downloadCount >= 1;
|
||||
case false:
|
||||
return downloadCount === 0;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const queryFilter = (query: NullAndUndefined<string>, { title }: TManga): boolean => {
|
||||
if (!query) return true;
|
||||
return title.toLowerCase().includes(query.toLowerCase());
|
||||
};
|
||||
|
||||
const queryGenreFilter = (query: NullAndUndefined<string>, { genre }: TManga): boolean => {
|
||||
if (!query) return true;
|
||||
const queries = query.split(',').map((str) => str.toLowerCase().trim());
|
||||
return queries.every((element) => genre.map((el) => el.toLowerCase()).includes(element));
|
||||
};
|
||||
|
||||
const filterManga = (
|
||||
mangas: TManga[],
|
||||
query: NullAndUndefined<string>,
|
||||
unread: NullAndUndefined<boolean>,
|
||||
downloaded: NullAndUndefined<boolean>,
|
||||
ignoreFilters: boolean,
|
||||
): TManga[] =>
|
||||
mangas.filter((manga) => {
|
||||
const ignoreFiltersWhileSearching = ignoreFilters && query?.length;
|
||||
const matchesSearch = queryFilter(query, manga) || queryGenreFilter(query, manga);
|
||||
const matchesFilters =
|
||||
ignoreFiltersWhileSearching || (downloadedFilter(downloaded, manga) && unreadFilter(unread, manga));
|
||||
|
||||
return matchesSearch && matchesFilters;
|
||||
});
|
||||
|
||||
const sortByUnread = (a: TManga, b: TManga): number => (a.unreadCount ?? 0) - (b.unreadCount ?? 0);
|
||||
|
||||
const sortByTitle = (a: TManga, b: TManga): number => a.title.localeCompare(b.title);
|
||||
|
||||
const sortByDateAdded = (a: TManga, b: TManga): number => Number(a.inLibraryAt) - Number(b.inLibraryAt);
|
||||
|
||||
const sortByLastRead = (a: TManga, b: TManga): number =>
|
||||
Number(b.lastReadChapter?.lastReadAt ?? 0) - Number(a.lastReadChapter?.lastReadAt ?? 0);
|
||||
|
||||
const sortManga = (
|
||||
manga: TManga[],
|
||||
sort: NullAndUndefined<LibrarySortMode>,
|
||||
desc: NullAndUndefined<boolean>,
|
||||
): TManga[] => {
|
||||
const result = [...manga];
|
||||
|
||||
switch (sort) {
|
||||
case 'sortAlph':
|
||||
result.sort(sortByTitle);
|
||||
break;
|
||||
case 'sortDateAdded':
|
||||
result.sort(sortByDateAdded);
|
||||
break;
|
||||
case 'sortToRead':
|
||||
result.sort(sortByUnread);
|
||||
break;
|
||||
case 'sortLastRead':
|
||||
result.sort(sortByLastRead);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (desc === true) {
|
||||
result.reverse();
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
interface LibraryMangaGridProps {
|
||||
interface LibraryMangaGridProps
|
||||
extends Required<Pick<IMangaGridProps, 'isSelectModeActive' | 'selectedMangaIds' | 'handleSelection'>> {
|
||||
mangas: TManga[];
|
||||
showFilteredOutMessage: boolean;
|
||||
isLoading: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const LibraryMangaGrid: React.FC<LibraryMangaGridProps> = ({ mangas, isLoading, message }) => {
|
||||
export const LibraryMangaGrid: React.FC<LibraryMangaGridProps> = ({
|
||||
mangas,
|
||||
showFilteredOutMessage,
|
||||
isLoading,
|
||||
message,
|
||||
isSelectModeActive,
|
||||
selectedMangaIds,
|
||||
handleSelection,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [query] = useQueryParam('query', StringParam);
|
||||
const { options } = useLibraryOptionsContext();
|
||||
const { unread, downloaded } = options;
|
||||
const { settings } = useSearchSettings();
|
||||
|
||||
const filteredMangas = useMemo(
|
||||
() => filterManga(mangas, query, unread, downloaded, settings.ignoreFilters),
|
||||
[mangas, query, unread, downloaded, settings.ignoreFilters],
|
||||
);
|
||||
const sortedMangas = useMemo(
|
||||
() => sortManga(filteredMangas, options.sorts, options.sortDesc),
|
||||
[filteredMangas, options.sorts, options.sortDesc],
|
||||
);
|
||||
|
||||
const showFilteredOutMessage =
|
||||
(unread != null || downloaded != null || query) && filteredMangas.length === 0 && mangas.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
@@ -135,12 +42,15 @@ export const LibraryMangaGrid: React.FC<LibraryMangaGridProps> = ({ mangas, isLo
|
||||
|
||||
return (
|
||||
<MangaGrid
|
||||
mangas={sortedMangas}
|
||||
mangas={mangas}
|
||||
isLoading={isLoading}
|
||||
hasNextPage={false}
|
||||
loadMore={() => undefined}
|
||||
message={showFilteredOutMessage ? t('library.error.label.no_matches') : message}
|
||||
gridLayout={options.gridLayout}
|
||||
isSelectModeActive={isSelectModeActive}
|
||||
selectedMangaIds={selectedMangaIds}
|
||||
handleSelection={handleSelection}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
126
src/components/library/useGetVisibleLibraryMangas.ts
Normal file
126
src/components/library/useGetVisibleLibraryMangas.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -106,7 +106,7 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
|
||||
const chapters = useMemo(() => chaptersData?.chapters.nodes ?? [], [chaptersData?.chapters.nodes]);
|
||||
|
||||
const { areNoItemsSelected, areAllItemsSelected, selectedItemIds, handleSelectAll, handleSelection } =
|
||||
useSelectableCollection(chapters.length);
|
||||
useSelectableCollection(chapters.length, { currentKey: 'default' });
|
||||
|
||||
const { settings: metadataServerSettings } = useMetadataServerSettings();
|
||||
|
||||
|
||||
127
src/components/manga/MangaActionMenu.tsx
Normal file
127
src/components/manga/MangaActionMenu.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
114
src/components/manga/MangaOptionButton.tsx
Normal file
114
src/components/manga/MangaOptionButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
105
src/components/manga/MangasSelectionFABActionItems.tsx
Normal file
105
src/components/manga/MangasSelectionFABActionItems.tsx
Normal 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import MoreHoriz from '@mui/icons-material/MoreHoriz';
|
||||
import { Fab, Menu, Box } from '@mui/material';
|
||||
import { Fab, Menu, Box, styled } from '@mui/material';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DEFAULT_FAB_STYLE } from '@/components/util/StyledFab';
|
||||
@@ -19,6 +19,16 @@ interface SelectionFABProps {
|
||||
title: TranslationKey;
|
||||
}
|
||||
|
||||
const FabContainer = styled(Box)(({ theme }) => ({
|
||||
...DEFAULT_FAB_STYLE,
|
||||
height: `calc(${DEFAULT_FAB_STYLE.height} + 1)`,
|
||||
paddingTop: '8px',
|
||||
zIndex: 1, // the "Checkbox" (MUI) component of the "ChapterCard" has z-index 1, which causes it to take over the mouse events
|
||||
[theme.breakpoints.down('md')]: {
|
||||
marginBottom: '64px',
|
||||
},
|
||||
}));
|
||||
|
||||
export const SelectionFAB: React.FC<SelectionFABProps> = ({ children, selectedItemsCount, title }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -27,15 +37,7 @@ export const SelectionFAB: React.FC<SelectionFABProps> = ({ children, selectedIt
|
||||
const handleClose = () => setOpen(false);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
...DEFAULT_FAB_STYLE,
|
||||
height: `calc(${DEFAULT_FAB_STYLE.height} + 1)`,
|
||||
pt: 1,
|
||||
zIndex: 1, // the "Checkbox" (MUI) component of the "ChapterCard" has z-index 1, which causes it to take over the mouse events
|
||||
}}
|
||||
ref={anchorEl}
|
||||
>
|
||||
<FabContainer ref={anchorEl}>
|
||||
<Fab variant="extended" color="primary" id="selectionMenuButton" onClick={() => setOpen(true)}>
|
||||
{`${selectedItemsCount} ${t(title, { count: selectedItemsCount })}`}
|
||||
<MoreHoriz sx={{ ml: 1 }} />
|
||||
@@ -53,6 +55,6 @@ export const SelectionFAB: React.FC<SelectionFABProps> = ({ children, selectedIt
|
||||
>
|
||||
{children(handleClose)}
|
||||
</Menu>
|
||||
</Box>
|
||||
</FabContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,33 +6,80 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import FormGroup from '@mui/material/FormGroup';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { Mangas } from '@/lib/data/Mangas.ts';
|
||||
import { useSelectableCollection } from '@/components/collection/useSelectableCollection.ts';
|
||||
import { ThreeStateCheckboxInput } from '@/components/atoms/ThreeStateCheckboxInput.tsx';
|
||||
|
||||
interface IProps {
|
||||
type BaseProps = {
|
||||
open: boolean;
|
||||
setOpen: (value: boolean) => void;
|
||||
mangaId: number;
|
||||
}
|
||||
};
|
||||
|
||||
export function CategorySelect(props: IProps) {
|
||||
type SingleMangaModeProps = {
|
||||
mangaId: number;
|
||||
};
|
||||
|
||||
type MultiMangaModeProps = {
|
||||
mangaIds: number[];
|
||||
};
|
||||
|
||||
type Props =
|
||||
| (BaseProps & SingleMangaModeProps & PropertiesNever<MultiMangaModeProps>)
|
||||
| (BaseProps & PropertiesNever<SingleMangaModeProps> & MultiMangaModeProps);
|
||||
|
||||
const useGetMangaCategoryIds = (mangaId: number | undefined): number[] => {
|
||||
const { data: mangaResult } = requestManager.useGetManga(mangaId ?? -1, { skip: mangaId === undefined });
|
||||
|
||||
return useMemo(() => {
|
||||
if (mangaId === undefined || !mangaResult) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return mangaResult.manga.categories.nodes.map((category) => category.id);
|
||||
}, [mangaResult?.manga.categories.nodes, mangaId]);
|
||||
};
|
||||
|
||||
const getCategoryCheckedState = (
|
||||
categoryId: number,
|
||||
categoriesToAdd: number[],
|
||||
categoriesToRemove: number[],
|
||||
isSingleSelectionMode: boolean,
|
||||
): boolean | undefined => {
|
||||
if (categoriesToAdd.includes(categoryId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isSingleSelectionMode) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (categoriesToRemove.includes(categoryId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export function CategorySelect(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { open, setOpen, mangaId } = props;
|
||||
const { open, setOpen, mangaId, mangaIds: passedMangaIds } = props;
|
||||
|
||||
const { data: mangaResult } = requestManager.useGetManga(mangaId);
|
||||
const isSingleSelectionMode = mangaId !== undefined;
|
||||
const mangaIds = passedMangaIds ?? [mangaId];
|
||||
|
||||
const mangaCategories = useGetMangaCategoryIds(mangaId);
|
||||
const { data } = requestManager.useGetCategories();
|
||||
const categoriesData = data?.categories.nodes;
|
||||
const [triggerMutate] = requestManager.useUpdateMangaCategories();
|
||||
|
||||
const allCategories = useMemo(() => {
|
||||
const cats = [...(categoriesData ?? [])]; // make copy
|
||||
@@ -42,29 +89,45 @@ export function CategorySelect(props: IProps) {
|
||||
return cats;
|
||||
}, [categoriesData]);
|
||||
|
||||
const selectedIds = mangaResult?.manga.categories.nodes.map((c) => c.id) ?? [];
|
||||
const { handleSelection, setSelectionForKey, getSelectionForKey } = useSelectableCollection<
|
||||
number,
|
||||
'categoriesToAdd' | 'categoriesToRemove'
|
||||
>(allCategories.length, {
|
||||
currentKey: 'categoriesToAdd',
|
||||
initialState: {
|
||||
categoriesToAdd: mangaCategories,
|
||||
categoriesToRemove: [],
|
||||
},
|
||||
});
|
||||
|
||||
const categoriesToAdd = getSelectionForKey('categoriesToAdd');
|
||||
const categoriesToRemove = getSelectionForKey('categoriesToRemove');
|
||||
|
||||
const handleCancel = () => {
|
||||
setSelectionForKey('categoriesToAdd', mangaCategories);
|
||||
setSelectionForKey('categoriesToRemove', []);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleOk = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>, categoryId: number) => {
|
||||
const { checked } = event.target as HTMLInputElement;
|
||||
const addToCategories = isSingleSelectionMode
|
||||
? categoriesToAdd.filter((categoryId) => !mangaCategories.includes(categoryId))
|
||||
: categoriesToAdd;
|
||||
const removeFromCategories = isSingleSelectionMode
|
||||
? mangaCategories.filter((categoryId) => !categoriesToAdd.includes(categoryId))
|
||||
: categoriesToRemove;
|
||||
|
||||
// TODO - update to only update categories when clicking OK - can now be updated in one go with graphql
|
||||
triggerMutate({
|
||||
variables: {
|
||||
input: {
|
||||
id: mangaId,
|
||||
patch: {
|
||||
addToCategories: checked ? [categoryId] : [],
|
||||
removeFromCategories: !checked ? [categoryId] : [],
|
||||
},
|
||||
},
|
||||
const isUpdateRequired = !!addToCategories.length || !!removeFromCategories.length;
|
||||
if (!isUpdateRequired) {
|
||||
return;
|
||||
}
|
||||
|
||||
Mangas.performAction('change_categories', mangaIds, {
|
||||
changeCategoriesPatch: {
|
||||
addToCategories,
|
||||
removeFromCategories,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -91,14 +154,25 @@ export function CategorySelect(props: IProps) {
|
||||
</span>
|
||||
)}
|
||||
{allCategories.map((category) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(category.id)}
|
||||
onChange={(e) => handleChange(e, category.id)}
|
||||
color="default"
|
||||
/>
|
||||
}
|
||||
<ThreeStateCheckboxInput
|
||||
checked={getCategoryCheckedState(
|
||||
category.id,
|
||||
categoriesToAdd,
|
||||
categoriesToRemove,
|
||||
isSingleSelectionMode,
|
||||
)}
|
||||
onChange={(checked) => {
|
||||
handleSelection(category.id, false, 'categoriesToAdd');
|
||||
handleSelection(category.id, false, 'categoriesToRemove');
|
||||
|
||||
if (checked) {
|
||||
handleSelection(category.id, true, 'categoriesToAdd');
|
||||
}
|
||||
|
||||
if (checked === false) {
|
||||
handleSelection(category.id, true, 'categoriesToRemove');
|
||||
}
|
||||
}}
|
||||
label={category.name}
|
||||
key={category.id}
|
||||
/>
|
||||
|
||||
@@ -446,6 +446,34 @@
|
||||
"title": "Library"
|
||||
},
|
||||
"manga": {
|
||||
"action": {
|
||||
"library": {
|
||||
"remove": {
|
||||
"button": {
|
||||
"selected": "Remove selected from the library"
|
||||
},
|
||||
"label": {
|
||||
"action": "Remove from the library",
|
||||
"error_one": "Could not remove manga from the library",
|
||||
"error_other": "Could not remove manga from the library",
|
||||
"success_one": "Removed manga from the library",
|
||||
"success_other": "Removed {{count}} manga from the library"
|
||||
}
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"button": {
|
||||
"selected": "Change categories of selected"
|
||||
},
|
||||
"label": {
|
||||
"action": "Change categories",
|
||||
"error_one": "Could not change the categories of the manga",
|
||||
"error_other": "Could not change the categories of the manga",
|
||||
"success_one": "Changed categories of manga",
|
||||
"success_other": "Changed categories of {{count}} manga"
|
||||
}
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"add_to_library": "Add To Library",
|
||||
"in_library": "In Library"
|
||||
@@ -464,7 +492,9 @@
|
||||
"reload_from_source": "Reload data from source",
|
||||
"status": "Status"
|
||||
},
|
||||
"title": "Manga"
|
||||
"title": "Manga",
|
||||
"title_one": "Manga",
|
||||
"title_other": "Manga"
|
||||
},
|
||||
"reader": {
|
||||
"button": {
|
||||
|
||||
36
src/lib/data/Chapters.ts
Normal file
36
src/lib/data/Chapters.ts
Normal 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
227
src/lib/data/Mangas.ts
Normal 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}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2581,6 +2581,16 @@ export type GetChaptersQueryVariables = Exact<{
|
||||
|
||||
export type GetChaptersQuery = { __typename?: 'Query', chapters: { __typename?: 'ChapterNodeList', totalCount: number, nodes: Array<{ __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', unreadCount: number, downloadCount: number, artist?: string | null, author?: string | null, chaptersLastFetchedAt?: any | null, description?: string | null, genre: Array<string>, id: number, inLibrary: boolean, inLibraryAt: any, initialized: boolean, lastFetchedAt?: any | null, realUrl?: string | null, status: MangaStatus, thumbnailUrl?: string | null, title: string, url: string, categories: { __typename?: 'CategoryNodeList', totalCount: number, nodes: Array<{ __typename?: 'CategoryType', default: boolean, id: number, includeInUpdate: IncludeInUpdate, name: string, order: number, meta: Array<{ __typename?: 'CategoryMetaType', key: string, value: string }>, mangas: { __typename?: 'MangaNodeList', totalCount: number } }> }, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, meta: Array<{ __typename?: 'MangaMetaType', key: string, value: string }>, source?: { __typename?: 'SourceType', displayName: string, iconUrl: string, id: any, isConfigurable: boolean, isNsfw: boolean, lang: string, name: string, supportsLatest: boolean } | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> }>, pageInfo: { __typename?: 'PageInfo', endCursor?: any | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: any | null } } };
|
||||
|
||||
export type GetMangasChapterIdsWithStateQueryVariables = Exact<{
|
||||
mangaIds: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
|
||||
isDownloaded?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
isRead?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
isBookmarked?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
}>;
|
||||
|
||||
|
||||
export type GetMangasChapterIdsWithStateQuery = { __typename?: 'Query', chapters: { __typename?: 'ChapterNodeList', nodes: Array<{ __typename?: 'ChapterType', id: number, isDownloaded: boolean, isRead: boolean, isBookmarked: boolean }> } };
|
||||
|
||||
export type GetDownloadStatusQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -162,6 +162,14 @@ import {
|
||||
ResetWebuiUpdateStatusMutationVariables,
|
||||
GetDownloadStatusQuery,
|
||||
GetDownloadStatusQueryVariables,
|
||||
GetMangasChapterIdsWithStateQuery,
|
||||
GetMangasChapterIdsWithStateQueryVariables,
|
||||
ChapterConditionInput,
|
||||
UpdateMangasMutation,
|
||||
UpdateMangasMutationVariables,
|
||||
UpdateMangasCategoriesMutation,
|
||||
UpdateMangasCategoriesMutationVariables,
|
||||
UpdateMangaCategoriesPatchInput,
|
||||
} from '@/lib/graphql/generated/graphql.ts';
|
||||
import { GET_GLOBAL_METADATAS } from '@/lib/graphql/queries/GlobalMetadataQuery.ts';
|
||||
import { SET_GLOBAL_METADATA } from '@/lib/graphql/mutations/GlobalMetadataMutation.ts';
|
||||
@@ -178,6 +186,8 @@ import {
|
||||
SET_MANGA_METADATA,
|
||||
UPDATE_MANGA,
|
||||
UPDATE_MANGA_CATEGORIES,
|
||||
UPDATE_MANGAS,
|
||||
UPDATE_MANGAS_CATEGORIES,
|
||||
} from '@/lib/graphql/mutations/MangaMutation.ts';
|
||||
import { GET_MANGA, GET_MANGAS } from '@/lib/graphql/queries/MangaQuery.ts';
|
||||
import { GET_CATEGORIES, GET_CATEGORY_MANGAS } from '@/lib/graphql/queries/CategoryQuery.ts';
|
||||
@@ -195,7 +205,7 @@ import {
|
||||
START_DOWNLOADER,
|
||||
STOP_DOWNLOADER,
|
||||
} from '@/lib/graphql/mutations/DownloaderMutation.ts';
|
||||
import { GET_CHAPTERS } from '@/lib/graphql/queries/ChapterQuery.ts';
|
||||
import { GET_CHAPTERS, GET_MANGAS_CHAPTER_IDS_WITH_STATE } from '@/lib/graphql/queries/ChapterQuery.ts';
|
||||
import {
|
||||
GET_CHAPTER_PAGES_FETCH,
|
||||
GET_MANGA_CHAPTERS_FETCH,
|
||||
@@ -841,6 +851,12 @@ export class RequestManager {
|
||||
}
|
||||
}
|
||||
|
||||
public getGlobalMeta(
|
||||
options?: QueryOptions<GetGlobalMetadatasQueryVariables, GetGlobalMetadatasQuery>,
|
||||
): AbortabaleApolloQueryResponse<GetGlobalMetadatasQuery> {
|
||||
return this.doRequest(GQLMethod.QUERY, GET_GLOBAL_METADATAS, {}, options);
|
||||
}
|
||||
|
||||
public useGetGlobalMeta(
|
||||
options?: QueryHookOptions<GetGlobalMetadatasQuery, GetGlobalMetadatasQueryVariables>,
|
||||
): AbortableApolloUseQueryResponse<GetGlobalMetadatasQuery, GetGlobalMetadatasQueryVariables> {
|
||||
@@ -1337,7 +1353,8 @@ export class RequestManager {
|
||||
const wrappedMutate = (mutateOptions: Parameters<typeof mutate>[0]) =>
|
||||
mutate({
|
||||
onCompleted: () => {
|
||||
this.graphQLClient.client.cache.evict({ fieldName: 'mangas' });
|
||||
this.graphQLClient.client.cache.evict({ broadcast: true, fieldName: 'categories' });
|
||||
this.graphQLClient.client.cache.evict({ broadcast: true, fieldName: 'mangas' });
|
||||
},
|
||||
...mutateOptions,
|
||||
});
|
||||
@@ -1345,6 +1362,26 @@ export class RequestManager {
|
||||
return [wrappedMutate, result];
|
||||
}
|
||||
|
||||
public updateMangasCategories(
|
||||
mangaIds: number[],
|
||||
patch: UpdateMangaCategoriesPatchInput,
|
||||
options?: MutationOptions<UpdateMangasCategoriesMutation, UpdateMangasCategoriesMutationVariables>,
|
||||
): AbortableApolloMutationResponse<UpdateMangasCategoriesMutation> {
|
||||
const response = this.doRequest(
|
||||
GQLMethod.MUTATION,
|
||||
UPDATE_MANGAS_CATEGORIES,
|
||||
{ input: { ids: mangaIds, patch } },
|
||||
options,
|
||||
);
|
||||
|
||||
response.response.then(() => {
|
||||
this.graphQLClient.client.cache.evict({ broadcast: true, fieldName: 'categories' });
|
||||
this.graphQLClient.client.cache.evict({ broadcast: true, fieldName: 'mangas' });
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public updateManga(
|
||||
id: number,
|
||||
patch: UpdateMangaPatchInput,
|
||||
@@ -1364,6 +1401,27 @@ export class RequestManager {
|
||||
return result;
|
||||
}
|
||||
|
||||
public updateMangas(
|
||||
ids: number[],
|
||||
patch: UpdateMangaPatchInput,
|
||||
options?: MutationOptions<UpdateMangasMutation, UpdateMangasMutationVariables>,
|
||||
): AbortableApolloMutationResponse<UpdateMangasMutation> {
|
||||
const result = this.doRequest<UpdateMangasMutation, UpdateMangasMutationVariables>(
|
||||
GQLMethod.MUTATION,
|
||||
UPDATE_MANGAS,
|
||||
{ input: { ids, patch } },
|
||||
options,
|
||||
);
|
||||
|
||||
result.response.then(() => {
|
||||
this.graphQLClient.client.cache.evict({ fieldName: 'categories' });
|
||||
this.graphQLClient.client.cache.evict({ fieldName: 'category' });
|
||||
this.graphQLClient.client.cache.evict({ fieldName: 'mangas' });
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public setMangaMeta(
|
||||
mangaId: number,
|
||||
key: string,
|
||||
@@ -1401,6 +1459,22 @@ export class RequestManager {
|
||||
);
|
||||
}
|
||||
|
||||
public getMangasChapterIdsWithState(
|
||||
mangaIds: number[],
|
||||
states: Pick<ChapterConditionInput, 'isRead' | 'isDownloaded' | 'isBookmarked'>,
|
||||
options?: QueryOptions<GetMangasChapterIdsWithStateQueryVariables, GetMangasChapterIdsWithStateQuery>,
|
||||
): AbortabaleApolloQueryResponse<GetMangasChapterIdsWithStateQuery> {
|
||||
return this.doRequest(
|
||||
GQLMethod.QUERY,
|
||||
GET_MANGAS_CHAPTER_IDS_WITH_STATE,
|
||||
{ mangaIds, ...states },
|
||||
{
|
||||
fetchPolicy: 'no-cache',
|
||||
...options,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public getMangaChaptersFetch(
|
||||
mangaId: number | string,
|
||||
options?: MutationOptions<GetMangaChaptersFetchMutation, GetMangaChaptersFetchMutationVariables>,
|
||||
|
||||
@@ -61,7 +61,6 @@ const typePolicies: StrictTypedTypePolicies = {
|
||||
chapters: {
|
||||
keyArgs: ['condition', 'filter', 'orderBy', 'orderByType'],
|
||||
merge(existing, incoming) {
|
||||
console.log('merge chapters', { ...existing }, { ...incoming });
|
||||
if (existing == null) {
|
||||
return incoming;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { Chip, Tab, Tabs, styled, Box } from '@mui/material';
|
||||
import React, { useContext, useEffect, useMemo } from 'react';
|
||||
import React, { useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryParam, NumberParam } from 'use-query-params';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
@@ -20,6 +20,13 @@ import { AppbarSearch } from '@/components/util/AppbarSearch';
|
||||
import { UpdateChecker } from '@/components/library/UpdateChecker';
|
||||
import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
|
||||
import { NavBarContext } from '@/components/context/NavbarContext.tsx';
|
||||
import { useSelectableCollection } from '@/components/collection/useSelectableCollection.ts';
|
||||
import { TManga } from '@/typings.ts';
|
||||
import { SelectableCollectionSelectMode } from '@/components/collection/SelectableCollectionSelectMode.tsx';
|
||||
import { useGetVisibleLibraryMangas } from '@/components/library/useGetVisibleLibraryMangas.ts';
|
||||
import { SelectionFAB } from '@/components/manga/SelectionFAB.tsx';
|
||||
import { MangasSelectionFABActionItems } from '@/components/manga/MangasSelectionFABActionItems.tsx';
|
||||
import { PARTIAL_MANGA_FIELDS } from '@/lib/graphql/Fragments.ts';
|
||||
|
||||
const StyledGridWrapper = styled(Box)(({ theme }) => ({
|
||||
// TabsMenu height + TabsMenu bottom padding - grid item top padding
|
||||
@@ -82,8 +89,36 @@ export function Library() {
|
||||
data: categoryMangaResponse,
|
||||
error: mangaError,
|
||||
loading: mangaLoading,
|
||||
} = requestManager.useGetCategoryMangas(activeTab?.id, { skip: !activeTab, nextFetchPolicy: 'cache-only' });
|
||||
const mangas = categoryMangaResponse?.mangas.nodes ?? [];
|
||||
} = requestManager.useGetCategoryMangas(activeTab?.id, { skip: !activeTab });
|
||||
const categoryMangas = categoryMangaResponse?.mangas.nodes ?? [];
|
||||
const { visibleMangas: mangas, showFilteredOutMessage } = useGetVisibleLibraryMangas(categoryMangas);
|
||||
|
||||
const [isSelectModeActive, setIsSelectModeActive] = useState(false);
|
||||
const {
|
||||
areNoItemsForKeySelected: areNoItemsSelected,
|
||||
areAllItemsForKeySelected: areAllItemsSelected,
|
||||
selectedItemIds,
|
||||
handleSelectAll,
|
||||
handleSelection,
|
||||
} = useSelectableCollection<TManga['id'], string>(mangas.length, { currentKey: activeTab?.id.toString() });
|
||||
|
||||
const handleSelect = (id: number, selected: boolean) => {
|
||||
setIsSelectModeActive(!!(selectedItemIds.length + (selected ? 1 : -1)));
|
||||
handleSelection(id, selected);
|
||||
};
|
||||
|
||||
const selectedMangas = useMemo(
|
||||
() =>
|
||||
selectedItemIds.map(
|
||||
(id) =>
|
||||
requestManager.graphQLClient.client.cache.readFragment<TManga>({
|
||||
id: requestManager.graphQLClient.client.cache.identify({ __typename: 'MangaType', id }),
|
||||
fragment: PARTIAL_MANGA_FIELDS,
|
||||
fragmentName: 'PARTIAL_MANGA_FIELDS',
|
||||
})!,
|
||||
),
|
||||
[selectedItemIds.length],
|
||||
);
|
||||
|
||||
const { setTitle, setAction } = useContext(NavBarContext);
|
||||
useEffect(() => {
|
||||
@@ -91,22 +126,53 @@ export function Library() {
|
||||
const navBarTitle = (
|
||||
<TitleWithSizeTag>
|
||||
{title}
|
||||
{areCategoriesLoading || !options.showTabSize ? null : <TitleSizeTag label={librarySize} />}
|
||||
{options.showTabSize && <TitleSizeTag label={librarySize} />}
|
||||
</TitleWithSizeTag>
|
||||
);
|
||||
setTitle(navBarTitle, title);
|
||||
setAction(
|
||||
<>
|
||||
<AppbarSearch />
|
||||
<LibraryToolbarMenu />
|
||||
<UpdateChecker />
|
||||
{!isSelectModeActive && (
|
||||
<>
|
||||
<AppbarSearch />
|
||||
<LibraryToolbarMenu />
|
||||
<UpdateChecker />
|
||||
</>
|
||||
)}
|
||||
<SelectableCollectionSelectMode
|
||||
isActive={isSelectModeActive}
|
||||
areAllItemsSelected={areAllItemsSelected}
|
||||
areNoItemsSelected={areNoItemsSelected}
|
||||
onSelectAll={(selectAll) =>
|
||||
handleSelectAll(selectAll, [...new Set(mangas.map((manga) => manga.id))])
|
||||
}
|
||||
onModeChange={(checked) => {
|
||||
setIsSelectModeActive(checked);
|
||||
|
||||
if (checked) {
|
||||
handleSelectAll(true, [...new Set(mangas.map((manga) => manga.id))]);
|
||||
} else {
|
||||
tabs.forEach((tab) => handleSelectAll(false, [], tab.id.toString()));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
return () => {
|
||||
setTitle('');
|
||||
setAction(null);
|
||||
};
|
||||
}, [t, librarySize, areCategoriesLoading, options]);
|
||||
}, [
|
||||
t,
|
||||
librarySize,
|
||||
areCategoriesLoading,
|
||||
options,
|
||||
isSelectModeActive,
|
||||
areNoItemsSelected,
|
||||
areAllItemsSelected,
|
||||
selectedItemIds.length,
|
||||
mangas.length,
|
||||
]);
|
||||
|
||||
const handleTabChange = (newTab: number) => {
|
||||
setTabSearchParam(newTab);
|
||||
@@ -135,6 +201,10 @@ export function Library() {
|
||||
mangas={mangas}
|
||||
message={t('library.error.label.empty')}
|
||||
isLoading={activeTab != null && mangaLoading}
|
||||
selectedMangaIds={selectedItemIds}
|
||||
isSelectModeActive={isSelectModeActive}
|
||||
handleSelection={handleSelect}
|
||||
showFilteredOutMessage={showFilteredOutMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -143,49 +213,68 @@ export function Library() {
|
||||
const scrollableTabs = window.innerWidth < tabs.length * 160;
|
||||
|
||||
return (
|
||||
<StyledGridWrapper>
|
||||
<TabsMenu
|
||||
sx={{ borderBottom: 2, borderColor: 'divider' }}
|
||||
value={activeTab.order}
|
||||
onChange={(e, newTab) => handleTabChange(newTab)}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
centered={!scrollableTabs}
|
||||
variant={scrollableTabs ? 'scrollable' : 'fullWidth'}
|
||||
scrollButtons
|
||||
allowScrollButtonsMobile
|
||||
>
|
||||
<>
|
||||
<StyledGridWrapper>
|
||||
<TabsMenu
|
||||
sx={{ borderBottom: 2, borderColor: 'divider' }}
|
||||
value={activeTab.order}
|
||||
onChange={(e, newTab) => handleTabChange(newTab)}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
centered={!scrollableTabs}
|
||||
variant={scrollableTabs ? 'scrollable' : 'fullWidth'}
|
||||
scrollButtons
|
||||
allowScrollButtonsMobile
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
sx={{ display: 'flex' }}
|
||||
key={tab.id}
|
||||
label={
|
||||
<TitleWithSizeTag>
|
||||
{tab.name}
|
||||
{options.showTabSize ? <TitleSizeTag label={tab.mangas.totalCount} /> : null}
|
||||
</TitleWithSizeTag>
|
||||
}
|
||||
value={tab.order}
|
||||
/>
|
||||
))}
|
||||
</TabsMenu>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
sx={{ display: 'flex' }}
|
||||
key={tab.id}
|
||||
label={
|
||||
<TitleWithSizeTag>
|
||||
{tab.name}
|
||||
{options.showTabSize ? <TitleSizeTag label={tab.mangas.totalCount} /> : null}
|
||||
</TitleWithSizeTag>
|
||||
}
|
||||
value={tab.order}
|
||||
/>
|
||||
<TabPanel key={tab.order} index={tab.order} currentIndex={activeTab.order}>
|
||||
{tab === activeTab &&
|
||||
(mangaError ? (
|
||||
<EmptyView
|
||||
message={t('manga.error.label.request_failure')}
|
||||
messageExtra={mangaError.message ?? mangaError}
|
||||
/>
|
||||
) : (
|
||||
<LibraryMangaGrid
|
||||
mangas={mangas}
|
||||
message={t('library.error.label.empty')}
|
||||
isLoading={mangaLoading}
|
||||
selectedMangaIds={selectedItemIds}
|
||||
isSelectModeActive={isSelectModeActive}
|
||||
handleSelection={handleSelect}
|
||||
showFilteredOutMessage={showFilteredOutMessage}
|
||||
/>
|
||||
))}
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabsMenu>
|
||||
{tabs.map((tab) => (
|
||||
<TabPanel key={tab.order} index={tab.order} currentIndex={activeTab.order}>
|
||||
{tab === activeTab &&
|
||||
(mangaError ? (
|
||||
<EmptyView
|
||||
message={t('manga.error.label.request_failure')}
|
||||
messageExtra={mangaError.message ?? mangaError}
|
||||
/>
|
||||
) : (
|
||||
<LibraryMangaGrid
|
||||
mangas={mangas}
|
||||
message={t('library.error.label.empty')}
|
||||
isLoading={mangaLoading}
|
||||
/>
|
||||
))}
|
||||
</TabPanel>
|
||||
))}
|
||||
</StyledGridWrapper>
|
||||
</StyledGridWrapper>
|
||||
{isSelectModeActive && (
|
||||
<SelectionFAB selectedItemsCount={selectedItemIds.length} title="manga.title">
|
||||
{(handleClose) => (
|
||||
<MangasSelectionFABActionItems
|
||||
selectedMangas={selectedMangas}
|
||||
handleClose={(selectionModeState) => {
|
||||
handleClose();
|
||||
setIsSelectModeActive(selectionModeState);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SelectionFAB>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,3 +32,14 @@ export const useMetadataServerSettings = (): {
|
||||
|
||||
return { metadata, settings, loading };
|
||||
};
|
||||
|
||||
export const getMetadataServerSettings = async (): Promise<MetadataServerSettings> => {
|
||||
const { data, error } = await requestManager.getGlobalMeta().response;
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const metadata = convertFromGqlMeta(data?.metas.nodes);
|
||||
return getMetadataServerSettingsWithDefaultFallback(metadata);
|
||||
};
|
||||
|
||||
124
yarn.lock
124
yarn.lock
@@ -487,6 +487,13 @@
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/runtime@^7.20.6", "@babel/runtime@^7.23.5":
|
||||
version "7.23.6"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.6.tgz#c05e610dc228855dc92ef1b53d07389ed8ab521d"
|
||||
integrity sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/template@^7.18.10", "@babel/template@^7.20.7", "@babel/template@^7.22.15":
|
||||
version "7.22.15"
|
||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38"
|
||||
@@ -1400,11 +1407,29 @@
|
||||
clsx "^2.0.0"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
"@mui/base@5.0.0-beta.28":
|
||||
version "5.0.0-beta.28"
|
||||
resolved "https://registry.yarnpkg.com/@mui/base/-/base-5.0.0-beta.28.tgz#f072e55c0530f456ee5cb5cde2af788fdda3bf05"
|
||||
integrity sha512-KIoSc5sUFceeCaZTq5MQBapFzhHqMo4kj+4azWaCAjorduhcRQtN+BCgVHmo+gvEjix74bUfxwTqGifnu2fNTg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.23.5"
|
||||
"@floating-ui/react-dom" "^2.0.4"
|
||||
"@mui/types" "^7.2.11"
|
||||
"@mui/utils" "^5.15.1"
|
||||
"@popperjs/core" "^2.11.8"
|
||||
clsx "^2.0.0"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
"@mui/core-downloads-tracker@^5.14.18":
|
||||
version "5.14.18"
|
||||
resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.14.18.tgz#f8b187dc89756fa5c0b7d15aea537a6f73f0c2d8"
|
||||
integrity sha512-yFpF35fEVDV81nVktu0BE9qn2dD/chs7PsQhlyaV3EnTeZi9RZBuvoEfRym1/jmhJ2tcfeWXiRuHG942mQXJJQ==
|
||||
|
||||
"@mui/core-downloads-tracker@^5.15.1":
|
||||
version "5.15.1"
|
||||
resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.1.tgz#8aad47e2b198640244f05f6486a927ce362e814e"
|
||||
integrity sha512-y/nUEsWHyBzaKYp9zLtqJKrLod/zMNEWpMj488FuQY9QTmqBiyUhI2uh7PVaLqLewXRtdmG6JV0b6T5exyuYRw==
|
||||
|
||||
"@mui/icons-material@^5.14.18":
|
||||
version "5.14.18"
|
||||
resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-5.14.18.tgz#9e92964cde8c7ba32cf50438a83403dc283f2328"
|
||||
@@ -1412,6 +1437,24 @@
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.23.2"
|
||||
|
||||
"@mui/material@^5.0.0":
|
||||
version "5.15.1"
|
||||
resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.15.1.tgz#5fc15c6eb9efe4b62b0c30b13bf5fa042bda71a1"
|
||||
integrity sha512-WA5DVyvacxDakVyAhNqu/rRT28ppuuUFFw1bLpmRzrCJ4uw/zLTATcd4WB3YbB+7MdZNEGG/SJNWTDLEIyn3xQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.23.5"
|
||||
"@mui/base" "5.0.0-beta.28"
|
||||
"@mui/core-downloads-tracker" "^5.15.1"
|
||||
"@mui/system" "^5.15.1"
|
||||
"@mui/types" "^7.2.11"
|
||||
"@mui/utils" "^5.15.1"
|
||||
"@types/react-transition-group" "^4.4.10"
|
||||
clsx "^2.0.0"
|
||||
csstype "^3.1.2"
|
||||
prop-types "^15.8.1"
|
||||
react-is "^18.2.0"
|
||||
react-transition-group "^4.4.5"
|
||||
|
||||
"@mui/material@^5.14.18":
|
||||
version "5.14.18"
|
||||
resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.14.18.tgz#d0a89be3e27afe90135d542ddbf160b3f34e869c"
|
||||
@@ -1439,6 +1482,15 @@
|
||||
"@mui/utils" "^5.14.18"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
"@mui/private-theming@^5.15.1":
|
||||
version "5.15.1"
|
||||
resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-5.15.1.tgz#58fd8da48295e105067fa7361734ee0b166d9cca"
|
||||
integrity sha512-wTbzuy5KjSvCPE9UVJktWHJ0b/tD5biavY9wvF+OpYDLPpdXK52vc1hTDxSbdkHIFMkJExzrwO9GvpVAHZBnFQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.23.5"
|
||||
"@mui/utils" "^5.15.1"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
"@mui/styled-engine@^5.14.18":
|
||||
version "5.14.18"
|
||||
resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.14.18.tgz#82d427bc975b85cecdbab2fd9353ed6c2df7eae1"
|
||||
@@ -1449,6 +1501,16 @@
|
||||
csstype "^3.1.2"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
"@mui/styled-engine@^5.15.1":
|
||||
version "5.15.1"
|
||||
resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.15.1.tgz#00f179e51afe252022bf356f72354968f9c5bf25"
|
||||
integrity sha512-7WDZTJLqGexWDjqE9oAgjU8ak6hEtUw2yQU7SIYID5kLVO2Nj/Wi/KicbLsXnTsJNvSqePIlUIWTBSXwWJCPZw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.23.5"
|
||||
"@emotion/cache" "^11.11.0"
|
||||
csstype "^3.1.2"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
"@mui/system@^5.14.18":
|
||||
version "5.14.18"
|
||||
resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.14.18.tgz#0f671e8f0a5e8e965b79235d77c50098f54195b5"
|
||||
@@ -1463,6 +1525,25 @@
|
||||
csstype "^3.1.2"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
"@mui/system@^5.15.1":
|
||||
version "5.15.1"
|
||||
resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.15.1.tgz#e2a79b5e188ca89a3e58aa4d27e3484edf9e24b0"
|
||||
integrity sha512-LAnP0ls69rqW9eBgI29phIx/lppv+WDGI7b3EJN7VZIqw0RezA0GD7NRpV12BgEYJABEii6z5Q9B5tg7dsX0Iw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.23.5"
|
||||
"@mui/private-theming" "^5.15.1"
|
||||
"@mui/styled-engine" "^5.15.1"
|
||||
"@mui/types" "^7.2.11"
|
||||
"@mui/utils" "^5.15.1"
|
||||
clsx "^2.0.0"
|
||||
csstype "^3.1.2"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
"@mui/types@^7.2.11":
|
||||
version "7.2.11"
|
||||
resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.11.tgz#36b99a88f8010dc716128e568dc05681a69dc7ae"
|
||||
integrity sha512-KWe/QTEsFFlFSH+qRYf3zoFEj3z67s+qAuSnMMg+gFwbxG7P96Hm6g300inQL1Wy///gSRb8juX7Wafvp93m3w==
|
||||
|
||||
"@mui/types@^7.2.9":
|
||||
version "7.2.9"
|
||||
resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.9.tgz#730ee83a37af292a5973962f78ce5c95f31213a7"
|
||||
@@ -1478,6 +1559,16 @@
|
||||
prop-types "^15.8.1"
|
||||
react-is "^18.2.0"
|
||||
|
||||
"@mui/utils@^5.15.1":
|
||||
version "5.15.1"
|
||||
resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.15.1.tgz#71d69dc8c0f13a1fd6aca20b53ec496636e6b854"
|
||||
integrity sha512-V1/d0E3Bju5YdB59HJf2G0tnHrFEvWLN+f8hAXp9+JSNy/LC2zKyqUfPPahflR6qsI681P8G9r4mEZte/SrrYA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.23.5"
|
||||
"@types/prop-types" "^15.7.11"
|
||||
prop-types "^15.8.1"
|
||||
react-is "^18.2.0"
|
||||
|
||||
"@mui/x-date-pickers@^6.18.2":
|
||||
version "6.18.2"
|
||||
resolved "https://registry.yarnpkg.com/@mui/x-date-pickers/-/x-date-pickers-6.18.2.tgz#07f76c4a9ba022b8916607d9b3501c39160787c2"
|
||||
@@ -1773,7 +1864,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239"
|
||||
integrity sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==
|
||||
|
||||
"@types/prop-types@*", "@types/prop-types@^15.7.10":
|
||||
"@types/prop-types@*", "@types/prop-types@^15.7.10", "@types/prop-types@^15.7.11":
|
||||
version "15.7.11"
|
||||
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.11.tgz#2596fb352ee96a1379c657734d4b913a613ad563"
|
||||
integrity sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==
|
||||
@@ -1802,6 +1893,13 @@
|
||||
hoist-non-react-statics "^3.3.0"
|
||||
redux "^4.0.0"
|
||||
|
||||
"@types/react-transition-group@^4.4.10":
|
||||
version "4.4.10"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.10.tgz#6ee71127bdab1f18f11ad8fb3322c6da27c327ac"
|
||||
integrity sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-transition-group@^4.4.8":
|
||||
version "4.4.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.9.tgz#12a1a1b5b8791067198149867b0823fbace31579"
|
||||
@@ -2497,6 +2595,11 @@ chardet@^0.7.0:
|
||||
resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
|
||||
integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
|
||||
|
||||
classnames@^2.2.6:
|
||||
version "2.3.2"
|
||||
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924"
|
||||
integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==
|
||||
|
||||
clean-stack@^2.0.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b"
|
||||
@@ -4356,6 +4459,16 @@ map-cache@^0.2.0:
|
||||
resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf"
|
||||
integrity sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==
|
||||
|
||||
material-ui-popup-state@^5.0.10:
|
||||
version "5.0.10"
|
||||
resolved "https://registry.yarnpkg.com/material-ui-popup-state/-/material-ui-popup-state-5.0.10.tgz#1c2d42cbe9f04f60fa6e4cd8ceb8ff5a456dfc62"
|
||||
integrity sha512-gd0DI8skwCSdth/j/yndoIwNkS2eDusosTe5hyPZ3jbrMzDkbQBs+tBbwapQ9hLfgiVLwICd1mwyerUV9Y5Elw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.20.6"
|
||||
"@mui/material" "^5.0.0"
|
||||
classnames "^2.2.6"
|
||||
prop-types "^15.7.2"
|
||||
|
||||
memoize-one@^5.1.1:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
|
||||
@@ -4877,6 +4990,13 @@ react-beautiful-dnd@^13.1.1:
|
||||
redux "^4.0.4"
|
||||
use-memo-one "^1.1.1"
|
||||
|
||||
react-device-detect@^2.2.3:
|
||||
version "2.2.3"
|
||||
resolved "https://registry.yarnpkg.com/react-device-detect/-/react-device-detect-2.2.3.tgz#97a7ae767cdd004e7c3578260f48cf70c036e7ca"
|
||||
integrity sha512-buYY3qrCnQVlIFHrC5UcUoAj7iANs/+srdkwsnNjI7anr3Tt7UY6MqNxtMLlr0tMBied0O49UZVK8XKs3ZIiPw==
|
||||
dependencies:
|
||||
ua-parser-js "^1.0.33"
|
||||
|
||||
react-dom@^18.2.0:
|
||||
version "18.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
|
||||
@@ -5641,7 +5761,7 @@ typescript@^5.3.2:
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.2.tgz#00d1c7c1c46928c5845c1ee8d0cc2791031d4c43"
|
||||
integrity sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==
|
||||
|
||||
ua-parser-js@^1.0.35:
|
||||
ua-parser-js@^1.0.33, ua-parser-js@^1.0.35:
|
||||
version "1.0.37"
|
||||
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.37.tgz#b5dc7b163a5c1f0c510b08446aed4da92c46373f"
|
||||
integrity sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==
|
||||
|
||||
Reference in New Issue
Block a user