Move manga files into new folder

This commit is contained in:
schroda
2024-10-05 19:21:26 +02:00
parent cb731cefc0
commit 18fc225d6c
42 changed files with 66 additions and 59 deletions

View File

@@ -21,7 +21,7 @@ import PopupState, { bindMenu, bindTrigger } from 'material-ui-popup-state';
import Menu from '@mui/material/Menu';
import { requestManager } from '@/lib/requests/requests/RequestManager.ts';
import { ChapterCard } from '@/modules/chapter/components/cards/ChapterCard.tsx';
import { ResumeFab } from '@/components/manga/ResumeFAB.tsx';
import { ResumeFab } from '@/modules/manga/components/ResumeFAB.tsx';
import { filterAndSortChapters } from '@/modules/chapter/utils/ChapterList.util.tsx';
import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx';
import { ChaptersToolbarMenu } from '@/modules/chapter/components/ChaptersToolbarMenu.tsx';
@@ -41,7 +41,7 @@ import { ChaptersDownloadActionMenuItems } from '@/modules/chapter/components/ac
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { LoadingPlaceholder } from '@/modules/core/components/placeholder/LoadingPlaceholder.tsx';
import { GET_CHAPTERS_MANGA } from '@/lib/graphql/queries/ChapterQuery.ts';
import { Mangas } from '@/lib/data/Mangas.ts';
import { Mangas } from '@/modules/manga/services/Mangas.ts';
import { useNavBarContext } from '@/components/context/NavbarContext.tsx';
import { useResizeObserver } from '@/modules/core/hooks/useResizeObserver.tsx';
import { MediaQuery } from '@/lib/ui/MediaQuery.tsx';

View File

@@ -9,7 +9,7 @@
import MenuItem from '@mui/material/MenuItem';
import { useTranslation } from 'react-i18next';
import { useMetadataServerSettings } from '@/lib/metadata/metadataServerSettings.ts';
import { Mangas } from '@/lib/data/Mangas.ts';
import { Mangas } from '@/modules/manga/services/Mangas.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { MangaType } from '@/lib/graphql/generated/graphql.ts';

View File

@@ -18,7 +18,7 @@ import {
DownloadStatusFieldsFragment,
} from '@/lib/graphql/generated/graphql.ts';
import { CHAPTER_LIST_FIELDS } from '@/lib/graphql/fragments/ChapterFragments.ts';
import { MangaIdInfo } from '@/lib/data/Mangas.ts';
import { MangaIdInfo } from '@/modules/manga/services/Mangas.ts';
import { DirectionOffset, TranslationKey } from '@/Base.types.ts';

View File

@@ -0,0 +1,49 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { LongPressPointerHandlers, LongPressResult } from 'use-long-press/lib/use-long-press.types';
import { PopupState } from 'material-ui-popup-state/hooks';
import { GridLayout } from '@/components/context/LibraryOptionsContext.tsx';
import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts';
import { useManageMangaLibraryState } from '@/modules/manga/hooks/useManageMangaLibraryState.tsx';
import { MangaThumbnailInfo } from '@/modules/manga/services/Mangas.ts';
import { ChapterType, MangaType } from '@/lib/graphql/generated/graphql.ts';
import { SingleModeProps } from '@/modules/manga/components/MangaActionMenuItems.tsx';
import { IReaderSettings } from '@/typings';
export type MangaCardMode = 'default' | 'source' | 'migrate.search' | 'migrate.select' | 'duplicate';
type MangaCardBaseProps = Pick<MangaType, 'id' | 'title' | 'sourceId'> &
Omit<SingleModeProps['manga'], 'downloadCount' | 'unreadCount' | 'chapters'> &
Partial<Pick<MangaType, 'inLibrary' | 'downloadCount' | 'unreadCount'>> & {
firstUnreadChapter?: Pick<ChapterType, 'id' | 'sourceOrder'> | null;
};
type MangaCardSpecificProps = MangaCardBaseProps & MangaThumbnailInfo;
export interface MangaCardProps {
manga: MangaCardBaseProps;
gridLayout?: GridLayout;
inLibraryIndicator?: boolean;
selected?: boolean | null;
handleSelection?: SelectableCollectionReturnType<MangaType['id']>['handleSelection'];
mode?: MangaCardMode;
}
export type SpecificMangaCardProps = Omit<MangaCardProps, 'manga'> &
Pick<ReturnType<typeof useManageMangaLibraryState>, 'isInLibrary'> & {
manga: MangaCardSpecificProps;
longPressBind: LongPressResult<LongPressPointerHandlers>;
popupState: PopupState;
handleClick: (event: React.MouseEvent | React.TouchEvent) => void;
mangaLinkTo: string;
continueReadingButton: JSX.Element;
mangaBadges: JSX.Element;
};
export type MangaMetadataKeys = keyof IReaderSettings;

View File

@@ -0,0 +1,48 @@
/*
* 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 Button from '@mui/material/Button';
import Tooltip from '@mui/material/Tooltip';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import { Link } from 'react-router-dom';
export const ContinueReadingButton = ({
showContinueReadingButton,
nextChapterIndexToRead,
mangaLinkTo,
}: {
showContinueReadingButton: boolean;
nextChapterIndexToRead?: number;
mangaLinkTo: string;
}) => {
const { t } = useTranslation();
const isFirstChapter = nextChapterIndexToRead === 1;
if (!showContinueReadingButton || nextChapterIndexToRead === undefined) {
return null;
}
return (
<Tooltip title={t(isFirstChapter ? 'global.button.start' : 'global.button.resume')}>
<Button
variant="contained"
size="small"
sx={{ minWidth: 'unset', py: 0.5, px: 0.75 }}
component={Link}
to={`${mangaLinkTo}chapter/${nextChapterIndexToRead}`}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<PlayArrowIcon />
</Button>
</Tooltip>
);
};

View File

@@ -0,0 +1,211 @@
/*
* 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 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 FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
import Label from '@mui/icons-material/Label';
import { useMemo, useState } from 'react';
import SyncAltIcon from '@mui/icons-material/SyncAlt';
import { Link } from 'react-router-dom';
import SyncIcon from '@mui/icons-material/Sync';
import Dialog from '@mui/material/Dialog';
import {
actionToTranslationKey,
MangaAction,
MangaDownloadInfo,
MangaIdInfo,
Mangas,
MangaUnreadInfo,
} from '@/modules/manga/services/Mangas.ts';
import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts';
import { MenuItem } from '@/modules/core/components/menu/MenuItem.tsx';
import {
createGetMenuItemTitle,
createIsMenuItemDisabled,
createShouldShowMenuItem,
} from '@/modules/core/components/menu/Menu.utils.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { TrackManga } from '@/components/tracker/TrackManga.tsx';
import { useCategorySelect } from '@/components/navbar/action/useCategorySelect.tsx';
import { ChaptersDownloadActionMenuItems } from '@/modules/chapter/components/actions/ChaptersDownloadActionMenuItems.tsx';
import { NestedMenuItem } from '@/modules/core/components/menu/NestedMenuItem.tsx';
import { MangaChapterStatFieldsFragment, MangaType } from '@/lib/graphql/generated/graphql.ts';
const ACTION_DISABLES_SELECTION_MODE: MangaAction[] = ['remove_from_library'] as const;
type BaseProps = { onClose: (selectionModeState: boolean) => void; setHideMenu: (hide: boolean) => void };
export type SingleModeProps = {
manga: Pick<MangaType, 'id' | 'title' | 'sourceId'> & MangaDownloadInfo & MangaUnreadInfo;
handleSelection?: SelectableCollectionReturnType<MangaType['id']>['handleSelection'];
};
type SelectModeProps = {
selectedMangas: MangaChapterStatFieldsFragment[];
};
type Props =
| (BaseProps & SingleModeProps & PropertiesNever<SelectModeProps>)
| (BaseProps & PropertiesNever<SingleModeProps> & SelectModeProps);
export const MangaActionMenuItems = ({
manga,
handleSelection,
selectedMangas: passedSelectedMangas,
onClose,
setHideMenu,
}: Props) => {
const { t } = useTranslation();
const [isTrackDialogOpen, setIsTrackDialogOpen] = useState(false);
const isSingleMode = !!manga;
const selectedMangas = passedSelectedMangas ?? [];
const getMenuItemTitle = createGetMenuItemTitle(isSingleMode, actionToTranslationKey);
const shouldShowMenuItem = createShouldShowMenuItem(isSingleMode);
const isMenuItemDisabled = createIsMenuItemDisabled(isSingleMode);
const isFullyDownloaded = !!manga && manga.downloadCount === manga.chapters.totalCount;
const hasDownloadedChapters = !!manga?.downloadCount;
const hasUnreadChapters = !!manga?.unreadCount;
const hasReadChapters = !!manga && manga.unreadCount !== manga.chapters.totalCount;
const { openCategorySelect, CategorySelectComponent } = useCategorySelect({
mangaId: manga?.id,
mangaIds: passedSelectedMangas ? Mangas.getIds(selectedMangas) : undefined,
onClose: () => onClose(true),
addToLibrary: false,
});
const handleSelect = () => {
handleSelection?.(manga.id, true);
onClose(true);
};
const performAction = (action: MangaAction, mangas: MangaIdInfo[]) => {
Mangas.performAction(action, manga ? [manga.id] : Mangas.getIds(mangas), {
wasManuallyMarkedAsRead: true,
}).catch(defaultPromiseErrorHandler(`MangaActionMenuItems:performAction(${action})`));
onClose(!ACTION_DISABLES_SELECTION_MODE.includes(action));
};
const { downloadableMangas, downloadedMangas, unreadMangas, readMangas } = useMemo(
() => ({
downloadableMangas: [
...Mangas.getNotDownloaded(selectedMangas),
...Mangas.getPartiallyDownloaded(selectedMangas),
],
downloadedMangas: [
...Mangas.getPartiallyDownloaded(selectedMangas),
...Mangas.getFullyDownloaded(selectedMangas),
],
unreadMangas: [...Mangas.getUnread(selectedMangas), ...Mangas.getPartiallyRead(selectedMangas)],
readMangas: [...Mangas.getPartiallyRead(selectedMangas), ...Mangas.getFullyRead(selectedMangas)],
}),
[selectedMangas],
);
return (
<>
{!!handleSelection && isSingleMode && (
<MenuItem onClick={handleSelect} Icon={CheckBoxOutlineBlank} title={t('chapter.action.label.select')} />
)}
{shouldShowMenuItem(!isFullyDownloaded) && (
<NestedMenuItem
disabled={isMenuItemDisabled(!downloadableMangas.length)}
LeftIcon={Download}
label={getMenuItemTitle('download', downloadableMangas.length)}
parentMenuOpen
>
<ChaptersDownloadActionMenuItems
mangaIds={isSingleMode ? [manga.id] : Mangas.getIds(selectedMangas)}
closeMenu={() => onClose(true)}
/>
</NestedMenuItem>
)}
{shouldShowMenuItem(hasDownloadedChapters) && (
<MenuItem
Icon={Delete}
disabled={isMenuItemDisabled(!downloadedMangas.length)}
onClick={() => performAction('delete', downloadedMangas)}
title={getMenuItemTitle('delete', downloadedMangas.length)}
/>
)}
{shouldShowMenuItem(hasUnreadChapters) && (
<MenuItem
Icon={Done}
disabled={isMenuItemDisabled(!unreadMangas.length)}
onClick={() => performAction('mark_as_read', unreadMangas)}
title={getMenuItemTitle('mark_as_read', unreadMangas.length)}
/>
)}
{shouldShowMenuItem(hasReadChapters) && (
<MenuItem
Icon={RemoveDone}
disabled={isMenuItemDisabled(!readMangas.length)}
onClick={() => performAction('mark_as_unread', readMangas)}
title={getMenuItemTitle('mark_as_unread', readMangas.length)}
/>
)}
{isSingleMode && (
<Link
to={`/migrate/source/${manga?.sourceId}/manga/${manga?.id}/search?query=${manga?.title}`}
state={{ mangaTitle: manga?.title }}
style={{ textDecoration: 'none', color: 'inherit' }}
>
<MenuItem Icon={SyncAltIcon} title={getMenuItemTitle('migrate', selectedMangas.length)} />
</Link>
)}
{isSingleMode && (
<MenuItem
onClick={() => {
setIsTrackDialogOpen(true);
setHideMenu(true);
}}
Icon={SyncIcon}
title={getMenuItemTitle('track', selectedMangas.length)}
/>
)}
<MenuItem
onClick={() => {
openCategorySelect(true);
setHideMenu(true);
}}
Icon={Label}
title={getMenuItemTitle('change_categories', selectedMangas.length)}
/>
<MenuItem
onClick={() => performAction('remove_from_library', selectedMangas)}
Icon={FavoriteBorderIcon}
title={getMenuItemTitle('remove_from_library', selectedMangas.length)}
/>
{CategorySelectComponent}
{isTrackDialogOpen && (
<Dialog
open
maxWidth="md"
fullWidth
scroll="paper"
onClose={() => {
setIsTrackDialogOpen(false);
onClose(true);
}}
>
<TrackManga manga={manga!} />
</Dialog>
)}
</>
);
};

View File

@@ -0,0 +1,97 @@
/*
* 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 { styled } from '@mui/material/styles';
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import { MangaCardMode } from '@/modules/manga/MangaCard.types.tsx';
import { MediaQuery } from '@/lib/ui/MediaQuery.tsx';
import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext.tsx';
const BadgeContainer = styled('div')(({ theme }) => ({
display: 'flex',
height: 'fit-content',
borderRadius: theme.shape.borderRadius,
overflow: 'hidden',
}));
const Badge = styled(Typography)(({ theme }) => ({
color: theme.palette.primary.contrastText,
paddingInline: theme.spacing(0.3),
}));
export const MangaBadges = ({
inLibraryIndicator,
updateLibraryState,
isInLibrary,
unread,
downloadCount,
mode,
}: {
inLibraryIndicator?: boolean;
updateLibraryState: () => void;
isInLibrary: boolean;
unread?: number;
downloadCount?: number;
mode: MangaCardMode;
}) => {
const { t } = useTranslation();
const isTouchDevice = MediaQuery.useIsTouchDevice();
const {
options: { showUnreadBadge, showDownloadBadge },
} = useLibraryOptionsContext();
return (
<BadgeContainer>
{!isTouchDevice && inLibraryIndicator && mode === 'source' && (
<Button
className="source-manga-library-state-button"
component="div"
variant="contained"
size="small"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
updateLibraryState();
}}
sx={{
display: 'none',
}}
color={isInLibrary ? 'error' : 'primary'}
>
{t(isInLibrary ? 'manga.action.library.remove.label.action' : 'manga.button.add_to_library')}
</Button>
)}
{inLibraryIndicator && isInLibrary && (
<Typography
className="source-manga-library-state-indicator"
sx={{ backgroundColor: 'primary.dark', color: 'primary.contrastText', p: 0.3 }}
>
{t('manga.button.in_library')}
</Typography>
)}
{((showUnreadBadge && mode === 'default') || mode === 'duplicate') && (unread ?? 0) > 0 && (
<Badge sx={{ backgroundColor: 'primary.main', color: 'primary.contrastText' }}>{unread}</Badge>
)}
{((showDownloadBadge && mode === 'default') || mode === 'duplicate') && (downloadCount ?? 0) > 0 && (
<Badge
sx={{
backgroundColor: 'secondary.main',
color: 'secondary.contrastText',
}}
>
{downloadCount}
</Badge>
)}
</BadgeContainer>
);
};

View File

@@ -0,0 +1,360 @@
/*
* 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 FavoriteIcon from '@mui/icons-material/Favorite';
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
import { styled, useTheme } from '@mui/material/styles';
import { ComponentProps, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { t as translate } from 'i18next';
import Link from '@mui/material/Link';
import Typography from '@mui/material/Typography';
import { useLongPress } from 'use-long-press';
import Chip from '@mui/material/Chip';
import Tooltip from '@mui/material/Tooltip';
import LaunchIcon from '@mui/icons-material/Launch';
import Collapse from '@mui/material/Collapse';
import Stack from '@mui/material/Stack';
import IconButton from '@mui/material/IconButton';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import OpenInFullIcon from '@mui/icons-material/OpenInFull';
import Modal from '@mui/material/Modal';
import { bindPopover, bindTrigger, usePopupState } from 'material-ui-popup-state/hooks';
import { makeToast } from '@/lib/ui/Toast.ts';
import {
Mangas,
MangaThumbnailInfo,
MangaTrackRecordInfo,
statusToTranslationKey,
} from '@/modules/manga/services/Mangas.ts';
import { SpinnerImage } from '@/modules/core/components/SpinnerImage.tsx';
import { CustomIconButton } from '@/modules/core/components/buttons/CustomIconButton.tsx';
import { TrackMangaButton } from '@/modules/manga/components/TrackMangaButton.tsx';
import { useManageMangaLibraryState } from '@/modules/manga/hooks/useManageMangaLibraryState.tsx';
import { Metadata as BaseMetadata } from '@/modules/core/components/Metadata.tsx';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { MangaType, SourceType } from '@/lib/graphql/generated/graphql.ts';
import { useLocalStorage } from '@/modules/core/hooks/useStorage.tsx';
import { useResizeObserver } from '@/modules/core/hooks/useResizeObserver.tsx';
import { useMetadataServerSettings } from '@/lib/metadata/metadataServerSettings.ts';
import { shouldForwardProp } from '@/modules/core/utils/ShouldForwardProp.ts';
const DetailsWrapper = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
padding: theme.spacing(1),
[theme.breakpoints.up('md')]: {
flexBasis: '40%',
height: 'calc(100vh - 64px)',
overflowY: 'auto',
},
}));
type TopContentWrapperProps = {
url: string;
mangaThumbnailBackdrop: boolean;
};
const TopContentWrapper = styled('div', {
shouldForwardProp: shouldForwardProp<TopContentWrapperProps>(['url', 'mangaThumbnailBackdrop']),
})<TopContentWrapperProps>(({ theme, url, mangaThumbnailBackdrop }) => ({
position: 'relative',
backgroundImage: mangaThumbnailBackdrop ? `url(${url})` : undefined,
backgroundRepeat: mangaThumbnailBackdrop ? 'no-repeat' : undefined,
backgroundSize: mangaThumbnailBackdrop ? 'cover' : undefined,
borderRadius: mangaThumbnailBackdrop ? theme.shape.borderRadius : undefined,
'&::before': mangaThumbnailBackdrop && {
position: 'absolute',
display: 'inline-block',
content: '""',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: `linear-gradient(to top, ${theme.palette.background.default}, transparent 100%, transparent 1px),linear-gradient(to right, ${theme.palette.background.default}, transparent 50%, transparent 1px),linear-gradient(to bottom, ${theme.palette.background.default}, transparent 50%, transparent 1px),linear-gradient(to left, ${theme.palette.background.default}, transparent 50%, transparent 1px)`,
backdropFilter: 'blur(4.5px) brightness(0.75)',
},
}));
const ThumbnailMetadataWrapper = styled('div')(({ theme }) => ({
display: 'flex',
paddingBottom: theme.spacing(1),
}));
const MetadataContainer = styled('div')(({ theme }) => ({
zIndex: 1,
marginLeft: theme.spacing(1),
}));
const Metadata = (props: ComponentProps<typeof BaseMetadata>) => <BaseMetadata {...props} />;
const MangaButtonsContainer = styled('div')(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
}));
const OpenSourceButton = ({ url }: { url?: string | null }) => {
const { t } = useTranslation();
return (
<Tooltip title={t('global.button.open_site')}>
<CustomIconButton
size="medium"
disabled={!url}
component={Link}
href={url ?? undefined}
target="_blank"
rel="noreferrer"
variant="outlined"
>
<LaunchIcon />
</CustomIconButton>
</Tooltip>
);
};
function getSourceName(source?: Pick<SourceType, 'id' | 'displayName'> | null): string {
if (!source) {
return translate('global.label.unknown');
}
const isLocalSource = Number(source.id) === 0;
if (isLocalSource) {
return translate('source.local_source.title');
}
return source.displayName ?? source.id;
}
function getValueOrUnknown(val?: string | null) {
return val ?? translate('global.label.unknown');
}
const Thumbnail = ({ manga }: { manga: Partial<MangaThumbnailInfo> }) => {
const theme = useTheme();
const popupState = usePopupState({ variant: 'popover', popupId: 'manga-thumbnail-fullscreen' });
const [imageHeight, setImageHeight] = useState<number>();
const [imageElement, setImageElement] = useState<HTMLImageElement | null>(null);
useResizeObserver(
imageElement,
useCallback(() => setImageHeight(imageElement?.clientHeight), [imageElement]),
);
const [isImageReady, setIsImageReady] = useState(false);
const isImageHeightReady = isImageReady && !!imageHeight;
return (
<>
<Stack
sx={{
position: 'relative',
borderRadius: 1,
overflow: 'hidden',
backgroundColor: 'background.paper',
width: '150px',
height: `${isImageHeightReady ? imageHeight : 225}px`,
flexShrink: 0,
[theme.breakpoints.up('lg')]: {
width: '200px',
height: `${isImageHeightReady ? imageHeight : 300}px`,
},
[theme.breakpoints.up('xl')]: {
width: '300px',
height: `${isImageHeightReady ? imageHeight : 450}px`,
},
}}
>
<SpinnerImage
ref={setImageElement}
src={Mangas.getThumbnailUrl(manga)}
alt="Manga Thumbnail"
onImageLoad={() => setIsImageReady(true)}
/>
{isImageReady && (
<Stack
{...bindTrigger(popupState)}
sx={{
position: 'absolute',
top: 0,
bottom: 0,
width: '100%',
justifyContent: 'center',
alignItems: 'center',
opacity: 0,
'&:hover': {
background: 'rgba(0, 0, 0, 0.4)',
cursor: 'pointer',
opacity: 1,
},
}}
>
<OpenInFullIcon fontSize="large" color="primary" />
</Stack>
)}
</Stack>
<Modal {...bindPopover(popupState)} sx={{ outline: 0 }}>
<Stack
onClick={() => popupState.close()}
sx={{ height: '100vh', py: 2, outline: 0, justifyContent: 'center', alignItems: 'center' }}
>
<SpinnerImage
src={Mangas.getThumbnailUrl(manga)}
alt="Manga Thumbnail"
imgStyle={{ height: '100%', width: '100%', objectFit: 'contain' }}
/>
</Stack>
</Modal>
</>
);
};
const DESCRIPTION_COLLAPSED_SIZE = 75;
const DescriptionGenre = ({
manga: { description, genre: mangaGenres },
}: {
manga: Pick<MangaType, 'description' | 'genre'>;
}) => {
const [descriptionElement, setDescriptionElement] = useState<HTMLSpanElement | null>(null);
const [descriptionHeight, setDescriptionHeight] = useState<number>();
useResizeObserver(
descriptionElement,
useCallback(() => setDescriptionHeight(descriptionElement?.clientHeight), [descriptionElement]),
);
const [isCollapsed, setIsCollapsed] = useLocalStorage('isDescriptionGenreCollapsed', true);
const collapsedSize = description
? Math.min(DESCRIPTION_COLLAPSED_SIZE, descriptionHeight ?? DESCRIPTION_COLLAPSED_SIZE)
: 0;
const genres = useMemo(() => mangaGenres.filter(Boolean), [mangaGenres]);
return (
<>
{description && (
<Stack sx={{ position: 'relative' }}>
<Collapse collapsedSize={collapsedSize} in={!isCollapsed}>
<Typography
ref={setDescriptionElement}
style={{ whiteSpace: 'pre-line', textAlign: 'justify', textJustify: 'inter-word' }}
>
{description}
</Typography>
</Collapse>
<Stack
onClick={() => setIsCollapsed(!isCollapsed)}
sx={{
pt: 1,
alignItems: 'center',
cursor: 'pointer',
position: isCollapsed ? 'absolute' : null,
width: '100%',
bottom: 0,
background: (theme) =>
`linear-gradient(transparent 1px, ${theme.palette.background.default})`,
}}
>
<IconButton sx={{ color: (theme) => (theme.palette.mode === 'light' ? 'black' : 'text') }}>
{isCollapsed ? <ExpandMoreIcon /> : <ExpandLessIcon />}
</IconButton>
</Stack>
</Stack>
)}
<Stack
sx={{
flexDirection: 'row',
flexWrap: isCollapsed ? 'no-wrap' : 'wrap',
gap: 1,
overflowX: isCollapsed ? 'auto' : null,
}}
>
{genres.map((genre) => (
<Chip key={genre} label={genre} variant="outlined" />
))}
</Stack>
</>
);
};
export const MangaDetails = ({
manga,
}: {
manga: Pick<
MangaType,
'id' | 'title' | 'author' | 'artist' | 'status' | 'inLibrary' | 'realUrl' | 'description' | 'genre'
> &
MangaThumbnailInfo &
MangaTrackRecordInfo & {
source?: Pick<SourceType, 'id' | 'displayName'> | null;
};
}) => {
const { t } = useTranslation();
const {
settings: { mangaThumbnailBackdrop },
} = useMetadataServerSettings();
useEffect(() => {
if (!manga.source) {
makeToast(translate('source.error.label.source_not_found'), 'error');
}
}, [manga.source]);
const { CategorySelectComponent, updateLibraryState } = useManageMangaLibraryState(manga);
const copyTitleLongPressBind = useLongPress(async () => {
try {
await navigator.clipboard.writeText(manga.title);
makeToast(t('global.label.copied'), 'info');
} catch (e) {
defaultPromiseErrorHandler('MangaDetails::copyTitleLongPress')(e);
}
});
return (
<>
<DetailsWrapper>
<TopContentWrapper url={Mangas.getThumbnailUrl(manga)} mangaThumbnailBackdrop={mangaThumbnailBackdrop}>
<ThumbnailMetadataWrapper>
<Thumbnail manga={manga} />
<MetadataContainer>
<Typography
variant="h5"
component="h2"
sx={{ mb: 1, '@media not (pointer: fine)': { userSelect: 'none' } }}
{...copyTitleLongPressBind()}
>
{manga.title}
</Typography>
<Metadata title={t('manga.label.author')} value={getValueOrUnknown(manga.author)} />
<Metadata title={t('manga.label.artist')} value={getValueOrUnknown(manga.artist)} />
<Metadata title={t('manga.label.status')} value={t(statusToTranslationKey[manga.status])} />
<Metadata title={t('source.title_one')} value={getSourceName(manga.source)} />
</MetadataContainer>
</ThumbnailMetadataWrapper>
<MangaButtonsContainer>
<CustomIconButton
size="medium"
onClick={updateLibraryState}
variant={manga.inLibrary ? 'contained' : 'outlined'}
>
{manga.inLibrary ? <FavoriteIcon /> : <FavoriteBorderIcon />}
{manga.inLibrary ? t('manga.button.in_library') : t('manga.button.add_to_library')}
</CustomIconButton>
<TrackMangaButton manga={manga} />
<OpenSourceButton url={manga.realUrl} />
</MangaButtonsContainer>
</TopContentWrapper>
<DescriptionGenre manga={manga} />
</DetailsWrapper>
{CategorySelectComponent}
</>
);
};

View File

@@ -0,0 +1,388 @@
/*
* 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 React, {
ForwardedRef,
forwardRef,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import Grid, { Grid2TypeMap as GridTypeMap } from '@mui/material/Grid2';
import Box, { BoxProps } from '@mui/material/Box';
import { GridItemProps, GridStateSnapshot, VirtuosoGrid } from 'react-virtuoso';
import { useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx';
import { LoadingPlaceholder } from '@/modules/core/components/placeholder/LoadingPlaceholder.tsx';
import { MangaCard } from '@/modules/manga/components/cards/MangaCard.tsx';
import { GridLayout } from '@/components/context/LibraryOptionsContext';
import { useLocalStorage, useSessionStorage } from '@/modules/core/hooks/useStorage.tsx';
import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts';
import { DEFAULT_FULL_FAB_HEIGHT } from '@/modules/core/components/buttons/StyledFab.tsx';
import { AppStorage } from '@/lib/AppStorage.ts';
import { MangaCardProps } from '@/modules/manga/MangaCard.types.tsx';
import { MangaType } from '@/lib/graphql/generated/graphql.ts';
import { useResizeObserver } from '@/modules/core/hooks/useResizeObserver.tsx';
import { useNavBarContext } from '@/components/context/NavbarContext.tsx';
const GridContainer = React.forwardRef<HTMLDivElement, GridTypeMap['props']>(({ children, ...props }, ref) => (
<Grid {...props} ref={ref} container spacing={1}>
{children}
</Grid>
));
const GridItemContainerWithDimension = (
dimensions: number,
itemWidth: number,
gridLayout?: GridLayout,
maxColumns: number = 12,
) => {
const itemsPerRow = Math.ceil(dimensions / itemWidth);
const columnsPerItem = gridLayout === GridLayout.List ? maxColumns : maxColumns / itemsPerRow;
// MUI GridProps and Virtuoso GridItemProps use different types for the "ref" prop which conflict with each other
return ({ children, ...itemProps }: GridTypeMap['props'] & Omit<Partial<GridItemProps>, 'ref'>) => (
<Grid {...itemProps} size={columnsPerItem}>
{children}
</Grid>
);
};
type TManga = MangaCardProps['manga'];
const createMangaCard = (
manga: TManga,
gridLayout?: GridLayout,
inLibraryIndicator?: boolean,
isSelectModeActive: boolean = false,
selectedMangaIds?: MangaType['id'][],
handleSelection?: DefaultGridProps['handleSelection'],
mode?: MangaCardProps['mode'],
) => (
<MangaCard
key={manga.id}
manga={manga}
gridLayout={gridLayout}
inLibraryIndicator={inLibraryIndicator}
selected={isSelectModeActive ? selectedMangaIds?.includes(manga.id) : null}
handleSelection={handleSelection}
mode={mode}
/>
);
type DefaultGridProps = Pick<MangaCardProps, 'mode'> & {
isLoading: boolean;
mangas: TManga[];
inLibraryIndicator?: boolean;
GridItemContainer: (props: GridTypeMap['props'] & Partial<GridItemProps>) => JSX.Element;
gridLayout?: GridLayout;
isSelectModeActive?: boolean;
selectedMangaIds?: Required<MangaType['id']>[];
handleSelection?: SelectableCollectionReturnType<MangaType['id']>['handleSelection'];
};
const HorizontalGrid = forwardRef(
(
{
isLoading,
mangas,
inLibraryIndicator,
GridItemContainer,
gridLayout,
isSelectModeActive,
selectedMangaIds,
handleSelection,
mode,
}: DefaultGridProps,
ref: ForwardedRef<HTMLDivElement | null>,
) => (
<Grid
ref={ref}
container
spacing={1}
sx={{
width: '100%',
overflowX: 'auto',
display: '-webkit-inline-box',
flexWrap: 'nowrap',
}}
>
{isLoading ? (
<LoadingPlaceholder />
) : (
mangas.map((manga) => (
<GridItemContainer key={manga.id}>
{createMangaCard(
manga,
gridLayout,
inLibraryIndicator,
isSelectModeActive,
selectedMangaIds,
handleSelection,
mode,
)}
</GridItemContainer>
))
)}
</Grid>
),
);
export const getGridSnapshotKey = (location: ReturnType<typeof useLocation>) =>
`MangaGrid-snapshot-location-${location.key}`;
const VerticalGrid = forwardRef(
(
{
isLoading,
mangas,
inLibraryIndicator,
GridItemContainer,
gridLayout,
hasNextPage,
loadMore,
isSelectModeActive,
selectedMangaIds,
handleSelection,
mode,
}: DefaultGridProps & {
hasNextPage: boolean;
loadMore: () => void;
},
ref: ForwardedRef<HTMLDivElement | null>,
) => {
const location = useLocation<{ snapshot?: GridStateSnapshot }>();
const snapshotSessionKey = getGridSnapshotKey(location);
const [snapshot] = useSessionStorage<GridStateSnapshot | undefined>(snapshotSessionKey, undefined);
const persistGridStateTimeout = useRef<NodeJS.Timeout | undefined>();
const persistGridState = (gridState: GridStateSnapshot) => {
const currentUrl = window.location.href;
clearTimeout(persistGridStateTimeout.current);
persistGridStateTimeout.current = setTimeout(() => {
const didLocationChange = currentUrl !== window.location.href;
if (didLocationChange) {
return;
}
AppStorage.session.setItem(snapshotSessionKey, gridState, false);
}, 250);
};
useEffect(() => clearTimeout(persistGridStateTimeout.current), [location.key, persistGridStateTimeout.current]);
return (
<>
<Box ref={ref}>
<VirtuosoGrid
useWindowScroll
overscan={window.innerHeight * 0.25}
totalCount={mangas.length}
components={{
List: GridContainer,
Item: GridItemContainer,
}}
restoreStateFrom={snapshot}
stateChanged={persistGridState}
endReached={() => loadMore()}
itemContent={(index) =>
createMangaCard(
mangas[index],
gridLayout,
inLibraryIndicator,
isSelectModeActive,
selectedMangaIds,
handleSelection,
mode,
)
}
/>
</Box>
{/* render div to prevent UI jumping around when showing/hiding loading placeholder */
/* eslint-disable-next-line no-nested-ternary */}
{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
extends Omit<DefaultGridProps, 'GridItemContainer'>,
Partial<React.ComponentProps<typeof EmptyViewAbsoluteCentered>> {
hasNextPage: boolean;
loadMore: () => void;
horizontal?: boolean | undefined;
noFaces?: boolean | undefined;
gridWrapperProps?: Omit<BoxProps, 'ref'>;
}
export const MangaGrid: React.FC<IMangaGridProps> = ({
mangas,
isLoading,
message,
messageExtra,
hasNextPage,
loadMore,
gridLayout,
horizontal,
noFaces,
inLibraryIndicator,
isSelectModeActive,
selectedMangaIds,
handleSelection,
mode,
retry,
gridWrapperProps,
}) => {
const { t } = useTranslation();
const { navBarWidth } = useNavBarContext();
const gridRef = useRef<HTMLDivElement>(null);
const gridWrapperRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState(
gridWrapperRef.current?.offsetWidth ?? Math.max(0, document.documentElement.offsetWidth - navBarWidth),
);
const [gridItemWidth] = useLocalStorage<number>('ItemWidth', 300);
const GridItemContainer = useMemo(
() => GridItemContainerWithDimension(dimensions, gridItemWidth, gridLayout),
[dimensions, gridItemWidth, gridLayout],
);
// always show vertical scrollbar to prevent https://github.com/Suwayomi/Suwayomi-WebUI/issues/758
useLayoutEffect(() => {
// in case "overflow" is currently set to "hidden" that (most likely) means that a MUI modal is open and locks the scrollbar
// once this modal is closed MUI restores the previous "overflow" value, thus, reverting the just set "overflow" value
let timeout: NodeJS.Timeout;
const changeStyle = (timeoutMS: number) => {
timeout = setTimeout(() => {
if (document.body.style.overflow.includes('hidden')) {
changeStyle(250);
return;
}
document.body.style.overflowY = gridLayout === GridLayout.List ? 'auto' : 'scroll';
}, timeoutMS);
};
changeStyle(0);
return () => {
clearTimeout(timeout);
};
}, [gridLayout]);
useLayoutEffect(
() => () => {
document.body.style.overflowY = 'auto';
},
[],
);
useResizeObserver(
gridWrapperRef,
useCallback(() => {
const getDimensions = () => {
const gridWidth = gridWrapperRef.current?.offsetWidth;
if (!gridWidth) {
return document.documentElement.offsetWidth - navBarWidth;
}
return gridWidth;
};
setDimensions(getDimensions());
}, [navBarWidth]),
);
useResizeObserver(
gridRef,
useCallback(
(entries, resizeObserver) => {
const gridHeight = entries[0].target.clientHeight;
const isScrollbarVisible = gridHeight > document.documentElement.clientHeight;
if (isLoading) {
return;
}
if (!gridHeight) {
return;
}
if (isScrollbarVisible) {
resizeObserver.disconnect();
return;
}
loadMore();
resizeObserver.disconnect();
},
[gridRef, loadMore, isLoading],
),
);
const hasNoItems = !isLoading && mangas.length === 0;
if (hasNoItems) {
return (
<EmptyViewAbsoluteCentered
noFaces={noFaces}
message={message ?? t('manga.error.label.no_mangas_found')}
messageExtra={messageExtra}
retry={retry}
/>
);
}
return (
<Box {...gridWrapperProps} ref={gridWrapperRef} sx={{ ...gridWrapperProps?.sx, overflow: 'hidden' }}>
{horizontal ? (
<HorizontalGrid
ref={gridRef}
isLoading={isLoading}
mangas={mangas}
inLibraryIndicator={inLibraryIndicator}
GridItemContainer={GridItemContainer}
gridLayout={gridLayout}
isSelectModeActive={isSelectModeActive}
selectedMangaIds={selectedMangaIds}
handleSelection={handleSelection}
mode={mode}
/>
) : (
<VerticalGrid
ref={gridRef}
isLoading={isLoading}
mangas={mangas}
inLibraryIndicator={inLibraryIndicator}
GridItemContainer={GridItemContainer}
hasNextPage={hasNextPage}
loadMore={loadMore}
gridLayout={gridLayout}
isSelectModeActive={isSelectModeActive}
selectedMangaIds={selectedMangaIds}
handleSelection={handleSelection}
mode={mode}
/>
)}
</Box>
);
};

View File

@@ -0,0 +1,126 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useTranslation } from 'react-i18next';
import { BaseSyntheticEvent, MouseEvent, TouchEvent, ChangeEvent, useMemo, forwardRef, ForwardedRef } from 'react';
import Button from '@mui/material/Button';
import Tooltip from '@mui/material/Tooltip';
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/hooks';
import { bindTrigger } from 'material-ui-popup-state';
import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts';
import { MediaQuery } from '@/lib/ui/MediaQuery.tsx';
import { MangaType } from '@/lib/graphql/generated/graphql.ts';
export const MangaOptionButton = forwardRef(
(
{
id,
selected,
handleSelection,
asCheckbox = false,
popupState,
}: {
id: number;
selected?: boolean | null;
handleSelection?: SelectableCollectionReturnType<MangaType['id']>['handleSelection'];
asCheckbox?: boolean;
popupState: PopupState;
},
ref: ForwardedRef<HTMLButtonElement | null>,
) => {
const { t } = useTranslation();
const isTouchDevice = MediaQuery.useIsTouchDevice();
const bindTriggerProps = useMemo(() => bindTrigger(popupState), [popupState]);
const preventDefaultAction = (e: BaseSyntheticEvent) => {
e.stopPropagation();
e.preventDefault();
};
const handleSelectionChange = (e: ChangeEvent, isSelected: boolean) => {
preventDefaultAction(e);
handleSelection?.(id, isSelected);
};
const handleClick = (e: MouseEvent | TouchEvent) => {
if (isTouchDevice) return;
preventDefaultAction(e);
popupState.open(e);
bindTriggerProps.onClick(e as any);
};
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
ref={ref}
{...bindTriggerProps}
onClick={handleClick}
onTouchStart={handleClick}
aria-label="more"
size="large"
onMouseDown={preventDefaultAction}
>
<MoreVertIcon />
</IconButton>
</Tooltip>
);
}
return (
<Tooltip title={t('global.button.options')}>
<Button
ref={ref}
{...bindTriggerProps}
onClick={handleClick}
onTouchStart={handleClick}
className="manga-option-button"
size="small"
variant="contained"
sx={{
minWidth: 'unset',
paddingX: '0',
paddingY: '2.5px',
visibility: popupState.isOpen ? 'visible' : 'hidden',
pointerEvents: 'none',
'@media not (pointer: fine)': {
visibility: 'hidden',
pointerEvents: undefined,
},
}}
onMouseDown={(e) => e.stopPropagation()}
>
<MoreVertIcon />
</Button>
</Tooltip>
);
},
);

View File

@@ -0,0 +1,157 @@
/*
* 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 Label from '@mui/icons-material/Label';
import MoreHoriz from '@mui/icons-material/MoreHoriz';
import Refresh from '@mui/icons-material/Refresh';
import IconButton from '@mui/material/IconButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Tooltip from '@mui/material/Tooltip';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import SyncAltIcon from '@mui/icons-material/SyncAlt';
import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import { useCategorySelect } from '@/components/navbar/action/useCategorySelect.tsx';
import { MangaType } from '@/lib/graphql/generated/graphql.ts';
interface IProps {
manga: Pick<MangaType, 'id' | 'inLibrary' | 'sourceId' | 'title'>;
onRefresh: () => any;
refreshing: boolean;
}
export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isLargeScreen = useMediaQuery(theme.breakpoints.up('sm'));
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClose = () => {
setAnchorEl(null);
};
const { openCategorySelect, CategorySelectComponent } = useCategorySelect({
mangaId: manga.id,
});
return (
<>
{isLargeScreen && (
<>
<Tooltip title={t('manga.label.reload_from_source')}>
<IconButton
onClick={() => {
onRefresh();
}}
disabled={refreshing}
color="inherit"
>
<Refresh />
</IconButton>
</Tooltip>
{manga.inLibrary && (
<>
<Tooltip title={t('global.button.migrate')}>
<Link
to={`/migrate/source/${manga.sourceId}/manga/${manga.id}/search?query=${manga.title}`}
state={{ mangaTitle: manga.title }}
style={{ textDecoration: 'none', color: 'inherit' }}
>
<IconButton color="inherit">
<SyncAltIcon />
</IconButton>
</Link>
</Tooltip>
<Tooltip title={t('manga.label.edit_categories')}>
<IconButton
onClick={() => {
openCategorySelect(true);
}}
color="inherit"
>
<Label />
</IconButton>
</Tooltip>
</>
)}
</>
)}
{!isLargeScreen && (
<>
<IconButton
id="chaptersMenuButton"
aria-controls={open ? 'chaptersMenu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
onClick={(e) => setAnchorEl(e.currentTarget)}
color="inherit"
>
<MoreHoriz />
</IconButton>
<Menu
id="chaptersMenu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
'aria-labelledby': 'chaptersMenuButton',
}}
>
<MenuItem
onClick={() => {
onRefresh();
handleClose();
}}
disabled={refreshing}
>
<ListItemIcon>
<Refresh fontSize="small" />
</ListItemIcon>
<ListItemText>{t('manga.label.reload_from_source')}</ListItemText>
</MenuItem>
{manga.inLibrary && [
<MenuItem
key="migrate"
component={Link}
to={`/migrate/source/${manga.sourceId}/manga/${manga.id}/search?query=${manga.title}`}
state={{ mangaTitle: manga.title }}
style={{ textDecoration: 'none', color: 'inherit' }}
>
<ListItemIcon>
<SyncAltIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t('migrate.title')}</ListItemText>
</MenuItem>,
<MenuItem
key="categories"
onClick={() => {
openCategorySelect(true);
handleClose();
}}
>
<ListItemIcon>
<Label fontSize="small" />
</ListItemIcon>
<ListItemText>{t('manga.label.edit_categories')}</ListItemText>
</MenuItem>,
]}
</Menu>
</>
)}
{CategorySelectComponent}
</>
);
};

View File

@@ -0,0 +1,29 @@
/*
* 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 { Link } from 'react-router-dom';
import PlayArrow from '@mui/icons-material/PlayArrow';
import { useTranslation } from 'react-i18next';
import { StyledFab } from '@/modules/core/components/buttons/StyledFab.tsx';
interface ResumeFABProps {
chapterIndex: number;
mangaId: number;
}
export function ResumeFab(props: ResumeFABProps) {
const { t } = useTranslation();
const { chapterIndex, mangaId } = props;
return (
<StyledFab component={Link} variant="extended" color="primary" to={`/manga/${mangaId}/chapter/${chapterIndex}`}>
<PlayArrow />
{chapterIndex === 1 ? t('global.button.start') : t('global.button.resume')}
</StyledFab>
);
}

View File

@@ -0,0 +1,73 @@
/*
* 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 { useNavigate } from 'react-router-dom';
import SyncIcon from '@mui/icons-material/Sync';
import { useTranslation } from 'react-i18next';
import PopupState, { bindDialog, bindTrigger } from 'material-ui-popup-state';
import Dialog from '@mui/material/Dialog';
import CheckIcon from '@mui/icons-material/Check';
import { requestManager } from '@/lib/requests/requests/RequestManager.ts';
import { makeToast } from '@/lib/ui/Toast.ts';
import { TrackManga } from '@/components/tracker/TrackManga.tsx';
import { Trackers } from '@/lib/data/Trackers.ts';
import { CustomIconButton } from '@/modules/core/components/buttons/CustomIconButton.tsx';
import { GetTrackersSettingsQuery, MangaType } from '@/lib/graphql/generated/graphql.ts';
import { GET_TRACKERS_SETTINGS } from '@/lib/graphql/queries/TrackerQuery.ts';
import { MangaTrackRecordInfo } from '@/modules/manga/services/Mangas.ts';
export const TrackMangaButton = ({ manga }: { manga: MangaTrackRecordInfo & Pick<MangaType, 'title'> }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const trackerList = requestManager.useGetTrackerList<GetTrackersSettingsQuery>(GET_TRACKERS_SETTINGS);
const trackers = trackerList.data?.trackers.nodes ?? [];
const mangaTrackers = manga.trackRecords.nodes;
const loggedInTrackers = Trackers.getLoggedIn(trackers);
const trackersInUse = Trackers.getLoggedIn(Trackers.getTrackers(mangaTrackers, trackers));
const handleClick = (openPopup: () => void) => {
if (trackerList.error) {
makeToast(t('tracking.error.label.could_not_load_track_info'), 'error');
return;
}
if (!loggedInTrackers.length) {
navigate('/settings/trackingSettings');
return;
}
openPopup();
};
return (
<PopupState variant="dialog" popupId="manga-track-modal">
{(popupState) => (
<>
<CustomIconButton
{...bindTrigger(popupState)}
size="medium"
disabled={trackerList.loading || !!trackerList.error}
onClick={() => handleClick(popupState.open)}
onTouchStart={() => handleClick(popupState.open)}
variant={trackersInUse.length ? 'contained' : 'outlined'}
>
{trackersInUse.length ? <CheckIcon /> : <SyncIcon />}
{trackersInUse.length
? t('manga.button.track.active', { count: trackersInUse.length })
: t('manga.button.track.start')}
</CustomIconButton>
<Dialog {...bindDialog(popupState)} maxWidth="md" fullWidth scroll="paper">
<TrackManga manga={manga} />
</Dialog>
</>
)}
</PopupState>
);
};

View File

@@ -0,0 +1,168 @@
/*
* 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 PopupState, { bindMenu } from 'material-ui-popup-state';
import { useMemo, useState } from 'react';
import { useLongPress } from 'use-long-press';
import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import { MangaActionMenuItems, SingleModeProps } from '@/modules/manga/components/MangaActionMenuItems.tsx';
import { Menu } from '@/modules/core/components/menu/Menu.tsx';
import { MigrateDialog } from '@/components/MigrateDialog.tsx';
import { useManageMangaLibraryState } from '@/modules/manga/hooks/useManageMangaLibraryState.tsx';
import { MangaGridCard } from '@/modules/manga/components/cards/MangaGridCard.tsx';
import { MangaListCard } from '@/modules/manga/components/cards/MangaListCard.tsx';
import { MangaCardMode, MangaCardProps } from '@/modules/manga/MangaCard.types.tsx';
import { ContinueReadingButton } from '@/modules/manga/components/ContinueReadingButton.tsx';
import { MangaBadges } from '@/modules/manga/components/MangaBadges.tsx';
const getMangaLinkTo = (
mode: MangaCardMode,
mangaId: number,
sourceId: string | undefined,
mangaTitle: string,
): string => {
switch (mode) {
case 'default':
case 'source':
case 'duplicate':
return `/manga/${mangaId}/`;
case 'migrate.search':
return `/migrate/source/${sourceId}/manga/${mangaId}/search?query=${mangaTitle}`;
case 'migrate.select':
return '';
default:
throw new Error(`getMangaLinkTo: unexpected MangaCardMode "${mode}"`);
}
};
export const MangaCard = (props: MangaCardProps) => {
const { manga, gridLayout, inLibraryIndicator, selected, handleSelection, mode = 'default' } = props;
const { id, firstUnreadChapter, downloadCount, unreadCount } = manga;
const {
options: { showContinueReadingButton },
} = useLibraryOptionsContext();
const { CategorySelectComponent, updateLibraryState, isInLibrary } = useManageMangaLibraryState(
manga,
mode === 'source',
);
const mangaLinkTo = getMangaLinkTo(mode, manga.id, manga.sourceId, manga.title);
const nextChapterIndexToRead = firstUnreadChapter?.sourceOrder;
const [isMigrateDialogOpen, setIsMigrateDialogOpen] = useState(false);
const handleClick = (event: React.MouseEvent | React.TouchEvent, openMenu?: () => void) => {
const isDefaultMode = mode === 'default';
const isSourceMode = mode === 'source';
const isMigrateSelectMode = mode === 'migrate.select';
const isSelectionMode = selected !== null;
const isLongPress = !!openMenu;
const shouldHandleClick =
isMigrateSelectMode || isSelectionMode || ((isDefaultMode || isSourceMode) && isLongPress);
if (!shouldHandleClick) {
return;
}
event.preventDefault();
if (isSourceMode) {
updateLibraryState();
return;
}
if (isSelectionMode) {
handleSelection?.(id, !selected, { selectRange: event.shiftKey });
return;
}
if (isDefaultMode) {
openMenu?.();
return;
}
if (isMigrateSelectMode) {
setIsMigrateDialogOpen(true);
}
};
const longPressBind = useLongPress((e, { context }) => {
e.shiftKey = true;
handleClick(e, context as () => {});
});
const MangaCardComponent = useMemo(
() => (gridLayout === GridLayout.List ? MangaListCard : MangaGridCard),
[gridLayout],
);
const continueReadingButton = useMemo(
() => (
<ContinueReadingButton
showContinueReadingButton={showContinueReadingButton && mode === 'default'}
nextChapterIndexToRead={nextChapterIndexToRead}
mangaLinkTo={mangaLinkTo}
/>
),
[showContinueReadingButton, nextChapterIndexToRead, mangaLinkTo],
);
const mangaBadges = useMemo(
() => (
<MangaBadges
inLibraryIndicator={inLibraryIndicator}
isInLibrary={isInLibrary}
unread={unreadCount}
downloadCount={downloadCount}
updateLibraryState={updateLibraryState}
mode={mode}
/>
),
[inLibraryIndicator, isInLibrary, unreadCount, downloadCount, updateLibraryState],
);
return (
<>
{isMigrateDialogOpen && (
<MigrateDialog mangaIdToMigrateTo={manga.id} onClose={() => setIsMigrateDialogOpen(false)} />
)}
<PopupState variant="popover" popupId="manga-card-action-menu">
{(popupState) => (
<>
<MangaCardComponent
{...props}
longPressBind={longPressBind}
popupState={popupState}
handleClick={handleClick}
mangaLinkTo={mangaLinkTo}
isInLibrary={isInLibrary}
inLibraryIndicator={inLibraryIndicator}
continueReadingButton={continueReadingButton}
mangaBadges={mangaBadges}
/>
{!!handleSelection && popupState.isOpen && (
<Menu {...bindMenu(popupState)}>
{(onClose, setHideMenu) => (
<MangaActionMenuItems
manga={manga as SingleModeProps['manga']}
handleSelection={handleSelection}
onClose={onClose}
setHideMenu={setHideMenu}
/>
)}
</Menu>
)}
{CategorySelectComponent}
</>
)}
</PopupState>
</>
);
};

View File

@@ -0,0 +1,203 @@
/*
* 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 Link from '@mui/material/Link';
import { Link as RouterLink } from 'react-router-dom';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardActionArea from '@mui/material/CardActionArea';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import { styled } from '@mui/material/styles';
import { useRef } from 'react';
import { SpinnerImage } from '@/modules/core/components/SpinnerImage.tsx';
import { MangaOptionButton } from '@/modules/manga/components/MangaOptionButton.tsx';
import { GridLayout } from '@/components/context/LibraryOptionsContext.tsx';
import { Mangas } from '@/modules/manga/services/Mangas.ts';
import { SpecificMangaCardProps } from '@/modules/manga/MangaCard.types.tsx';
import { TypographyMaxLines } from '@/modules/core/components/TypographyMaxLines.tsx';
const BottomGradient = styled('div')({
position: 'absolute',
bottom: 0,
width: '100%',
height: '30%',
background: 'linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 100%)',
});
const BottomGradientDoubledDown = styled('div')({
position: 'absolute',
bottom: 0,
width: '100%',
height: '20%',
background: 'linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 100%)',
});
export const MangaGridCard = ({
manga,
longPressBind,
popupState,
handleClick,
mangaLinkTo,
selected,
inLibraryIndicator,
isInLibrary,
gridLayout,
handleSelection,
continueReadingButton,
mangaBadges,
mode,
}: SpecificMangaCardProps) => {
const optionButtonRef = useRef<HTMLButtonElement>(null);
const { id, title } = manga;
return (
<Link
component={RouterLink}
{...longPressBind(() => popupState.open(optionButtonRef.current))}
onClick={handleClick}
to={mangaLinkTo}
state={{ mangaTitle: title }}
sx={{ textDecoration: 'none', touchCallout: 'none' }}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
m: 0.25,
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',
},
'&:hover .source-manga-library-state-button': {
display: 'inline-flex',
},
'&:hover .source-manga-library-state-indicator': {
display: mode === 'source' ? 'none' : 'flex',
},
},
}}
>
<Card
sx={{
// force standard aspect ratio of manga covers
aspectRatio: '225/350',
display: 'flex',
}}
>
<CardActionArea
sx={{
position: 'relative',
height: '100%',
}}
>
<SpinnerImage
alt={title}
src={Mangas.getThumbnailUrl(manga)}
imgStyle={
inLibraryIndicator && isInLibrary
? {
height: '100%',
width: '100%',
objectFit: 'cover',
filter: 'brightness(0.4)',
}
: {
height: '100%',
width: '100%',
objectFit: 'cover',
}
}
spinnerStyle={{
display: 'grid',
placeItems: 'center',
}}
/>
<Stack
direction="row"
sx={{
alignItems: 'start',
justifyContent: 'space-between',
position: 'absolute',
top: (theme) => theme.spacing(1),
left: (theme) => theme.spacing(1),
right: (theme) => theme.spacing(1),
}}
>
{mangaBadges}
<MangaOptionButton
ref={optionButtonRef}
popupState={popupState}
id={id}
selected={selected}
handleSelection={handleSelection}
/>
</Stack>
<>
{gridLayout !== GridLayout.Comfortable && (
<>
<BottomGradient />
<BottomGradientDoubledDown />
</>
)}
<Stack
direction="row"
sx={{
justifyContent: gridLayout !== GridLayout.Comfortable ? 'space-between' : 'end',
alignItems: 'end',
position: 'absolute',
bottom: 0,
width: '100%',
p: 1,
gap: 1,
}}
>
{gridLayout !== GridLayout.Comfortable && (
<Tooltip title={title} placement="top">
<TypographyMaxLines
component="h3"
sx={{
color: 'white',
textShadow: '0px 0px 3px #000000',
}}
>
{title}
</TypographyMaxLines>
</Tooltip>
)}
{continueReadingButton}
</Stack>
</>
</CardActionArea>
</Card>
{gridLayout === GridLayout.Comfortable && (
<Stack sx={{ pb: 1 }}>
<Tooltip title={title} placement="top">
<TypographyMaxLines
component="h3"
sx={{
color: (theme) => (selected ? theme.palette.primary.contrastText : 'text.primary'),
height: '3rem',
pt: 0.5,
}}
>
{title}
</TypographyMaxLines>
</Tooltip>
</Stack>
)}
</Box>
</Link>
);
};

View File

@@ -0,0 +1,133 @@
/*
* 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 Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardActionArea from '@mui/material/CardActionArea';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import { Link as RouterLink } from 'react-router-dom';
import { useRef } from 'react';
import { SpinnerImage } from '@/modules/core/components/SpinnerImage.tsx';
import { TypographyMaxLines } from '@/modules/core/components/TypographyMaxLines.tsx';
import { SpecificMangaCardProps } from '@/modules/manga/MangaCard.types.tsx';
import { Mangas } from '@/modules/manga/services/Mangas.ts';
import { MangaOptionButton } from '@/modules/manga/components/MangaOptionButton.tsx';
export const MangaListCard = ({
manga,
longPressBind,
popupState,
handleClick,
mangaLinkTo,
selected,
inLibraryIndicator,
isInLibrary,
handleSelection,
continueReadingButton,
mangaBadges,
mode,
}: SpecificMangaCardProps) => {
const optionButtonRef = useRef<HTMLButtonElement>(null);
const { id, title } = manga;
return (
<Card>
<CardActionArea
component={RouterLink}
to={mangaLinkTo}
state={{ mangaTitle: title }}
onClick={handleClick}
{...longPressBind(() => popupState.open(optionButtonRef.current))}
sx={{
touchCallout: 'none',
'@media (hover: hover) and (pointer: fine)': {
'&:hover .manga-option-button': {
visibility: 'visible',
pointerEvents: 'all',
},
'&:hover .source-manga-library-state-button': {
display: 'inline-flex',
},
'&:hover .source-manga-library-state-indicator': {
display: mode === 'source' ? 'none' : 'inline-flex',
},
},
}}
>
<CardContent
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: 1.5,
position: 'relative',
}}
>
<Avatar
variant="rounded"
sx={{
width: 56,
height: 56,
flex: '0 0 auto',
marginRight: 1,
}}
>
<SpinnerImage
spinnerStyle={{ small: true }}
imgStyle={{
objectFit: 'cover',
width: '100%',
height: '100%',
imageRendering: 'pixelated',
filter: inLibraryIndicator && isInLibrary ? 'brightness(0.4)' : undefined,
}}
alt={manga.title}
src={Mangas.getThumbnailUrl(manga)}
/>
</Avatar>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
flexGrow: 1,
width: 'min-content',
}}
>
<Tooltip title={title} placement="top">
<TypographyMaxLines variant="h6" component="h3">
{title}
</TypographyMaxLines>
</Tooltip>
</Box>
<Stack
direction="row"
sx={{
alignItems: 'center',
gap: 0.5,
}}
>
{mangaBadges}
{continueReadingButton}
<MangaOptionButton
ref={optionButtonRef}
popupState={popupState}
id={id}
selected={selected}
handleSelection={handleSelection}
asCheckbox
/>
</Stack>
</CardContent>
</CardActionArea>
</Card>
);
};

View File

@@ -0,0 +1,176 @@
/*
* 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 { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import gql from 'graphql-tag';
import { useCategorySelect } from '@/components/navbar/action/useCategorySelect.tsx';
import { requestManager } from '@/lib/requests/requests/RequestManager.ts';
import { makeToast } from '@/lib/ui/Toast.ts';
import { getMetadataServerSettings } from '@/lib/metadata/metadataServerSettings.ts';
import { Categories } from '@/lib/data/Categories.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { Mangas } from '@/modules/manga/services/Mangas.ts';
import { awaitConfirmation } from '@/lib/ui/AwaitableDialog.tsx';
import { GetCategoriesBaseQuery, GetCategoriesBaseQueryVariables, MangaType } from '@/lib/graphql/generated/graphql.ts';
import { GET_CATEGORIES_BASE } from '@/lib/graphql/queries/CategoryQuery.ts';
export const useManageMangaLibraryState = (
manga: Pick<MangaType, 'id' | 'title'> & Partial<Pick<MangaType, 'inLibrary'>>,
confirmRemoval: boolean = false,
) => {
const { t } = useTranslation();
const navigate = useNavigate();
const [isInLibrary, setIsInLibrary] = useState(!!manga.inLibrary);
const addToLibrary = useCallback(
(didSubmit: boolean, addToCategories: number[] = [], removeFromCategories: number[] = []) => {
if (!didSubmit) {
return;
}
requestManager
.updateManga(manga.id, {
updateManga: { inLibrary: true },
updateMangaCategories: { addToCategories, removeFromCategories },
})
.response.then(() => makeToast(t('library.info.label.added_to_library'), 'success'))
.then(() => setIsInLibrary(true))
.catch(() => {
makeToast(t('library.error.label.add_to_library'), 'error');
});
},
[manga.id],
);
const removeFromLibrary = useCallback(async () => {
if (confirmRemoval) {
await awaitConfirmation({
title: t('global.label.are_you_sure'),
message: t('manga.action.library.remove.dialog.label.message', { title: manga.title }),
actions: {
confirm: { title: t('global.button.remove') },
},
});
}
await Mangas.removeFromLibrary([manga.id]);
setIsInLibrary(false);
}, [manga.id, confirmRemoval]);
const { openCategorySelect, CategorySelectComponent } = useCategorySelect({
mangaId: manga.id,
addToLibrary: true,
onClose: addToLibrary,
});
const updateLibraryState = useCallback(() => {
const update = async () => {
if (isInLibrary) {
removeFromLibrary().catch(
defaultPromiseErrorHandler('useManageMangaLibraryState::updateLibraryState::removeFromLibrary'),
);
return;
}
let showAddToLibraryCategorySelectDialog: boolean;
try {
showAddToLibraryCategorySelectDialog = (await getMetadataServerSettings())
.showAddToLibraryCategorySelectDialog;
} catch (e) {
makeToast(t('global.error.label.failed_to_load_data'), 'error');
return;
}
let categories: Awaited<
ReturnType<
typeof requestManager.getCategories<GetCategoriesBaseQuery, GetCategoriesBaseQueryVariables>
>['response']
>;
try {
categories = await requestManager.getCategories<
GetCategoriesBaseQuery,
GetCategoriesBaseQueryVariables
>(GET_CATEGORIES_BASE).response;
} catch (e) {
makeToast(t('category.error.label.request_failure'), 'error');
return;
}
const userCreatedCategories = Categories.getUserCreated(categories.data.categories.nodes);
let duplicatedLibraryMangas:
| Awaited<ReturnType<typeof Mangas.getDuplicateLibraryMangas>['response']>
| undefined;
try {
duplicatedLibraryMangas = await Mangas.getDuplicateLibraryMangas(manga.title).response;
} catch (e: any) {
await awaitConfirmation({
title: t('global.error.label.failed_to_load_data'),
message: t('manga.action.library.add.dialog.duplicate.label.failure', {
error: e.message,
}),
actions: {
extra: { show: true, title: t('global.button.retry'), contain: true },
confirm: { title: t('global.button.add') },
},
onExtra: () =>
update().catch(
defaultPromiseErrorHandler('useManageMangaLibraryState::update: retry duplicate check'),
),
});
}
const doDuplicatesExist = duplicatedLibraryMangas?.data.mangas.totalCount;
if (doDuplicatesExist) {
await awaitConfirmation({
title: t('global.label.are_you_sure'),
message: t('manga.action.library.add.dialog.duplicate.label.info'),
actions: {
extra: { show: true, title: t('migrate.dialog.action.button.show_entry'), contain: true },
confirm: { title: t('global.button.add') },
},
onExtra: () => navigate(`/manga/${duplicatedLibraryMangas!.data.mangas.nodes[0].id}`),
});
}
const showCategorySelectDialog = showAddToLibraryCategorySelectDialog && !!userCreatedCategories.length;
if (!showCategorySelectDialog) {
addToLibrary(true, Categories.getIds(Categories.getDefaults(userCreatedCategories!)));
return;
}
openCategorySelect(true);
};
update().catch(defaultPromiseErrorHandler('useManageMangaLibraryState::updateLibraryState'));
}, [isInLibrary, removeFromLibrary, addToLibrary]);
return {
CategorySelectComponent,
updateLibraryState,
/**
* In case of browsing the source, the data has to be fetched via a mutation.
* Thus, the source browse data never has the updated in library state unless it has to rerender, which does not get
* triggered by updating the manga in this hook.
*
* To work around this issue, the currently known in library state gets returned here
*/
isInLibrary:
Mangas.getFromCache(
manga.id,
gql`
fragment MangaInLibraryState on MangaType {
inLibrary
}
`,
'MangaInLibraryState',
)?.inLibrary ?? isInLibrary,
};
};

View File

@@ -0,0 +1,36 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useCallback, useState } from 'react';
import { ApolloError } from '@apollo/client';
import { requestManager } from '@/lib/requests/requests/RequestManager.ts';
import { baseCleanup } from '@/lib/data/Strings.ts';
export const useRefreshManga = (mangaId: string) => {
const [fetchingOnline, setFetchingOnline] = useState(false);
const [error, setError] = useState(null);
const handleRefresh = useCallback(async () => {
setFetchingOnline(true);
setError(null);
await Promise.all([
requestManager.getMangaFetch(mangaId, { awaitRefetchQueries: true }).response,
requestManager.getMangaChaptersFetch(mangaId, { awaitRefetchQueries: true }).response,
])
.catch((e) => {
if (e instanceof ApolloError && baseCleanup(e.message) === 'no chapters found') {
return;
}
setError(e);
})
.finally(() => setFetchingOnline(false));
}, [mangaId]);
return [handleRefresh, { loading: fetchingOnline, error }] as const;
};

View File

@@ -0,0 +1,121 @@
/*
* 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 Warning from '@mui/icons-material/Warning';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import Box from '@mui/material/Box';
import React, { useContext, useEffect, useLayoutEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { isNetworkRequestInFlight } from '@apollo/client/core/networkStatus';
import { requestManager } from '@/lib/requests/requests/RequestManager.ts';
import { NavBarContext } from '@/components/context/NavbarContext.tsx';
import { ChapterList } from '@/modules/chapter/components/ChapterList.tsx';
import { useRefreshManga } from '@/modules/manga/hooks/useRefreshManga.ts';
import { MangaDetails } from '@/modules/manga/components/MangaDetails.tsx';
import { MangaToolbarMenu } from '@/modules/manga/components/MangaToolbarMenu.tsx';
import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx';
import { LoadingPlaceholder } from '@/modules/core/components/placeholder/LoadingPlaceholder.tsx';
import { GetMangaScreenQuery } from '@/lib/graphql/generated/graphql.ts';
import { GET_MANGA_SCREEN } from '@/lib/graphql/queries/MangaQuery.ts';
export const Manga: React.FC = () => {
const { t } = useTranslation();
const { setTitle, setAction } = useContext(NavBarContext);
const { id } = useParams<{ id: string }>();
const autofetchedRef = useRef(false);
const {
data,
error: mangaError,
loading: isLoading,
networkStatus,
refetch,
} = requestManager.useGetManga<GetMangaScreenQuery>(GET_MANGA_SCREEN, id);
const isValidating = isNetworkRequestInFlight(networkStatus);
const manga = data?.manga;
const [refresh, { loading: refreshing, error: refreshError }] = useRefreshManga(id);
const error = mangaError ?? refreshError;
useEffect(() => {
if (manga == null) return;
const doFetch = !autofetchedRef.current && !manga.initialized;
if (doFetch) {
autofetchedRef.current = true;
refresh();
}
}, [manga]);
useLayoutEffect(() => {
setTitle(manga?.title ?? t('manga.title_one'));
setAction(null);
return () => {
setTitle('');
setAction(null);
};
}, [t, manga?.title]);
useLayoutEffect(() => {
setAction(
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
{error && !isValidating && !refreshing && (
<Tooltip
title={
<>
{t('manga.error.label.request_failure')}
<br />
{error.message ?? error}
</>
}
>
<IconButton onClick={() => refetch()}>
<Warning color="error" />
</IconButton>
</Tooltip>
)}
{manga && (refreshing || isValidating) && (
<IconButton disabled>
<CircularProgress size={16} />
</IconButton>
)}
{manga && <MangaToolbarMenu manga={manga} onRefresh={refresh} refreshing={refreshing} />}
</Stack>,
);
return () => {
setAction(null);
};
}, [t, error, isValidating, refreshing, manga, refresh]);
if (error && !manga) {
return (
<EmptyViewAbsoluteCentered message={t('manga.error.label.request_failure')} messageExtra={error.message} />
);
}
return (
<Box sx={{ display: { md: 'flex' }, overflow: 'hidden' }}>
{isLoading && <LoadingPlaceholder />}
{manga && <MangaDetails manga={manga} />}
{manga && <ChapterList manga={manga} isRefreshing={refreshing} />}
</Box>
);
};

View File

@@ -0,0 +1,627 @@
/*
* 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 { DocumentNode } from '@apollo/client/core';
import { MetadataMigrationSettings } from '@/typings.ts';
import { requestManager } from '@/lib/requests/requests/RequestManager.ts';
import {
ChapterConditionInput,
GetMangasBaseQuery,
GetMangasBaseQueryVariables,
GetMangasChapterIdsWithStateQuery,
GetMangaToMigrateQuery,
GetMangaToMigrateToFetchMutation,
MangaBaseFieldsFragment,
MangaReaderFieldsFragment,
MangaStatus,
MangaType,
TrackRecordType,
UpdateMangaCategoriesPatchInput,
} from '@/lib/graphql/generated/graphql.ts';
import { Chapters } from '@/modules/chapter/services/Chapters.ts';
import { makeToast } from '@/lib/ui/Toast.ts';
import { getMetadataServerSettings } from '@/lib/metadata/metadataServerSettings.ts';
import { GET_MANGAS_BASE } from '@/lib/graphql/queries/MangaQuery.ts';
import { MANGA_BASE_FIELDS } from '@/lib/graphql/fragments/MangaFragments.ts';
import { TranslationKey } from '@/Base.types.ts';
export type MangaAction =
| 'download'
| 'delete'
| 'mark_as_read'
| 'mark_as_unread'
| 'remove_from_library'
| 'change_categories'
| 'migrate'
| 'track';
export const statusToTranslationKey: Record<MangaStatus, TranslationKey> = {
[MangaStatus.Cancelled]: 'manga.status.cancelled',
[MangaStatus.Completed]: 'manga.status.completed',
[MangaStatus.Licensed]: 'manga.status.licensed',
[MangaStatus.Ongoing]: 'manga.status.ongoing',
[MangaStatus.OnHiatus]: 'manga.status.hiatus',
[MangaStatus.PublishingFinished]: 'manga.status.publishing_finished',
[MangaStatus.Unknown]: 'manga.status.unknown',
};
export const actionToTranslationKey: {
[key in MangaAction]: {
action: {
single: TranslationKey;
selected: TranslationKey;
};
success: TranslationKey;
error: TranslationKey;
};
} = {
download: {
action: {
single: 'chapter.action.download.add.label.action',
selected: 'chapter.action.download.add.button.selected',
},
success: 'chapter.action.download.add.label.success',
error: 'chapter.action.download.add.label.error',
},
delete: {
action: {
single: 'chapter.action.download.delete.label.action',
selected: 'chapter.action.download.delete.button.selected',
},
success: 'chapter.action.download.delete.label.success',
error: 'chapter.action.download.delete.label.error',
},
mark_as_read: {
action: {
single: 'chapter.action.mark_as_read.add.label.action.current',
selected: 'chapter.action.mark_as_read.add.button.selected',
},
success: 'chapter.action.mark_as_read.add.label.success',
error: 'chapter.action.mark_as_read.add.label.error',
},
mark_as_unread: {
action: {
single: 'chapter.action.mark_as_read.remove.label.action',
selected: 'chapter.action.mark_as_read.remove.button.selected',
},
success: 'chapter.action.mark_as_read.remove.label.success',
error: 'chapter.action.mark_as_read.remove.label.error',
},
remove_from_library: {
action: {
single: 'manga.action.library.remove.label.action',
selected: 'manga.action.library.remove.button.selected',
},
success: 'manga.action.library.remove.label.success',
error: 'manga.action.library.remove.label.error',
},
change_categories: {
action: {
single: 'manga.action.category.label.action',
selected: 'manga.action.category.button.selected',
},
success: 'manga.action.category.label.success',
error: 'manga.action.category.label.error',
},
migrate: {
action: {
single: 'global.button.migrate',
selected: 'global.button.migrate', // not supported
},
success: 'manga.action.migrate.label.success',
error: 'manga.action.migrate.label.error',
},
track: {
action: {
single: 'manga.action.track.add.label.action',
selected: 'manga.action.track.add.label.action', // not supported
},
success: 'manga.action.track.add.label.success',
error: 'manga.action.track.add.label.error',
},
};
export type TMangaReader = MangaReaderFieldsFragment;
export type MangaIdInfo = Pick<MangaType, 'id'>;
export type MangaChapterCountInfo = { chapters: Pick<MangaType['chapters'], 'totalCount'> };
export type MangaDownloadInfo = Pick<MangaType, 'downloadCount'> & MangaChapterCountInfo;
export type MangaUnreadInfo = Pick<MangaType, 'unreadCount'> & MangaChapterCountInfo;
export type MangaThumbnailInfo = Pick<MangaType, 'thumbnailUrl' | 'thumbnailUrlLastFetched'>;
export type MangaTrackRecordInfo = MangaIdInfo & {
trackRecords: { nodes: Pick<TrackRecordType, 'id' | 'trackerId'>[] };
};
export type MigrateMode = 'copy' | 'migrate';
type MangaToMigrate = NonNullable<GetMangaToMigrateQuery['manga']>;
type MangaToMigrateTo = NonNullable<GetMangaToMigrateToFetchMutation['fetchManga']>['manga'];
type DownloadChaptersOptions = {
size?: number;
onlyUnread?: boolean;
downloadAhead?: boolean;
};
type MarkAsReadOptions = { wasManuallyMarkedAsRead: boolean };
type ChangeCategoriesOptions = { changeCategoriesPatch: UpdateMangaCategoriesPatchInput };
type MigrateOptions = {
mangaIdToMigrateTo: number;
mode: MigrateMode;
} & Partial<MetadataMigrationSettings>;
type MarkAsReadActionOption = MarkAsReadOptions &
PropertiesNever<ChangeCategoriesOptions> &
PropertiesNever<MigrateOptions> &
PropertiesNever<DownloadChaptersOptions>;
type ChangeCategoriesActionOption = PropertiesNever<MarkAsReadOptions> &
ChangeCategoriesOptions &
PropertiesNever<MigrateOptions> &
PropertiesNever<DownloadChaptersOptions>;
type MigrateActionOption = PropertiesNever<MarkAsReadOptions> &
PropertiesNever<ChangeCategoriesOptions> &
MigrateOptions &
PropertiesNever<DownloadChaptersOptions>;
type DownloadActionOption = PropertiesNever<MarkAsReadOptions> &
PropertiesNever<ChangeCategoriesOptions> &
PropertiesNever<MigrateOptions> &
DownloadChaptersOptions;
type DefaultActionOption = Partial<MarkAsReadOptions> &
Partial<ChangeCategoriesOptions> &
Partial<MigrateOptions> &
Partial<DownloadChaptersOptions>;
type PerformActionOptions<Action extends MangaAction> = Action extends 'mark_as_read'
? MarkAsReadActionOption
: Action extends 'change_categories'
? ChangeCategoriesActionOption
: Action extends 'migrate'
? MigrateActionOption
: Action extends 'download'
? DownloadActionOption
: DefaultActionOption;
type MigrateFuncReturn = { copy: () => Promise<unknown>[]; cleanup: () => Promise<unknown>[] };
export class Mangas {
static getIds(mangas: MangaIdInfo[]): number[] {
return mangas.map((manga) => manga.id);
}
static getFromCache<T = MangaBaseFieldsFragment>(
id: MangaIdInfo['id'],
fragment: DocumentNode = MANGA_BASE_FIELDS,
fragmentName: string = 'MANGA_BASE_FIELDS',
): T | null {
return requestManager.graphQLClient.client.cache.readFragment<T>({
id: requestManager.graphQLClient.client.cache.identify({
__typename: 'MangaType',
id,
}),
fragment,
fragmentName,
});
}
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 getThumbnailUrl(manga: Partial<MangaThumbnailInfo>): string {
const thumbnailUrl = manga.thumbnailUrl
? `${manga.thumbnailUrl}?fetchedAt=${manga.thumbnailUrlLastFetched}`
: '';
return requestManager.getValidImgUrlFor(thumbnailUrl);
}
static getDuplicateLibraryMangas(
title: string,
): ReturnType<typeof requestManager.getMangas<GetMangasBaseQuery, GetMangasBaseQueryVariables>> {
return requestManager.getMangas<GetMangasBaseQuery, GetMangasBaseQueryVariables>(GET_MANGAS_BASE, {
condition: { inLibrary: true },
filter: { title: { likeInsensitive: title } },
});
}
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[],
{ size, onlyUnread, downloadAhead = false }: DownloadChaptersOptions = {},
): Promise<void> {
const [chaptersToConsider, unReadDownloadedChapters] = await Promise.all([
Mangas.getChapterIdsWithState(mangaIds, {
isRead: onlyUnread ? false : undefined,
isDownloaded: false,
}),
downloadAhead ? Mangas.getChapterIdsWithState(mangaIds, { isRead: false, isDownloaded: true }) : [],
]);
type MangaIdToDownloadSize = [MangaId: string, DownloadSize: number | undefined];
const mangaIdToDefaultDownloadSize = mangaIds.map((mangaId) => [
String(mangaId),
size,
]) satisfies MangaIdToDownloadSize[];
const mangaIdToChaptersToConsider = Object.groupBy(chaptersToConsider, ({ mangaId }) => mangaId);
const mangaIdToUnReadDownloadedChapters = Object.groupBy(unReadDownloadedChapters, ({ mangaId }) => mangaId);
const mangaIdToDownloadSize = Object.entries(mangaIdToUnReadDownloadedChapters).map(
([mangaId, downloadedChapters = []]) => {
const downloadAheadSize = Math.max(0, (size ?? downloadedChapters.length) - downloadedChapters.length);
const actualSize = downloadAhead ? downloadAheadSize : size;
return [mangaId, actualSize];
},
) satisfies MangaIdToDownloadSize[];
const mangaIdToActualDownloadSize = Object.entries(
Object.fromEntries([...mangaIdToDefaultDownloadSize, ...mangaIdToDownloadSize]),
) satisfies MangaIdToDownloadSize[];
const chapterIdsToDownload = mangaIdToActualDownloadSize
.map(([mangaId, actualSize]) => {
const mangaChapters = mangaIdToChaptersToConsider[Number(mangaId)] ?? [];
if (!mangaChapters.length) {
return [];
}
const shouldDownloadAll = actualSize === undefined;
if (shouldDownloadAll) {
return mangaChapters;
}
const uniqueMangaChapters = Chapters.removeDuplicates(mangaChapters[0], mangaChapters);
const uniqueMangaChaptersToDownload = uniqueMangaChapters.slice(0, actualSize);
return Chapters.addDuplicates(uniqueMangaChaptersToDownload, mangaChapters);
})
.flat();
if (!chapterIdsToDownload.length) {
return Promise.resolve();
}
return Chapters.download(Chapters.getIds(chapterIdsToDownload));
}
static async deleteChapters(mangaIds: number[]): Promise<void> {
const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isDownloaded: true });
return Chapters.delete(Chapters.getIds(chapters));
}
static async markAsRead(mangaIds: number[], wasManuallyMarkedAsRead: boolean = false): Promise<void> {
const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isRead: false });
return Chapters.markAsRead(chapters, wasManuallyMarkedAsRead, mangaIds.length === 1 ? mangaIds[0] : undefined);
}
static async markAsUnread(mangaIds: number[]): Promise<void> {
const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isRead: true });
return Chapters.markAsUnread(Chapters.getIds(chapters));
}
static async removeFromLibrary(mangaIds: number[]): Promise<void> {
const { removeMangaFromCategories } = await getMetadataServerSettings();
return Mangas.executeAction(
'remove_from_library',
mangaIds.length,
() =>
requestManager.updateMangas(mangaIds, {
updateMangas: { inLibrary: false },
updateMangasCategories: removeMangaFromCategories ? { clearCategories: true } : undefined,
}).response,
);
}
static async changeCategories(mangaIds: number[], patch: UpdateMangaCategoriesPatchInput): Promise<void> {
return Mangas.executeAction(
'change_categories',
mangaIds.length,
() => requestManager.updateMangasCategories(mangaIds, patch).response,
);
}
private static migrateChapters(
mode: MigrateMode,
mangaToMigrate: GetMangaToMigrateQuery['manga'],
mangaToMigrateToInfo: GetMangaToMigrateToFetchMutation,
): MigrateFuncReturn {
if (!mangaToMigrate.chapters || !mangaToMigrateToInfo.fetchChapters?.chapters) {
throw new Error('Chapters are missing');
}
const chaptersToMigrate = mangaToMigrate.chapters.nodes;
const chaptersToMigrateTo = mangaToMigrateToInfo.fetchChapters?.chapters;
const migratableChapters = Chapters.getMatchingChapterNumberChapters(chaptersToMigrate, chaptersToMigrateTo);
const readChapters: number[] = [];
const bookmarkedChapters: number[] = [];
migratableChapters.forEach(([chapterToMigrate, chapterToMigrateTo]) => {
const { isRead, isBookmarked } = chapterToMigrate;
if (isRead) {
readChapters.push(chapterToMigrateTo.id);
}
if (isBookmarked) {
bookmarkedChapters.push(chapterToMigrateTo.id);
}
});
return {
copy: () =>
[
readChapters.length && requestManager.updateChapters(readChapters, { isRead: true }).response,
bookmarkedChapters.length &&
requestManager.updateChapters(bookmarkedChapters, { isBookmarked: true }).response,
].filter((promise) => !!promise),
cleanup: () =>
mode === 'migrate'
? [
requestManager.deleteDownloadedChapters(
Chapters.getIds(Chapters.getDownloaded(mangaToMigrate.chapters?.nodes ?? [])),
).response,
]
: [],
};
}
private static migrateTracking(
mode: MigrateMode,
mangaToMigrate: MangaToMigrate,
mangaToMigrateTo: MangaToMigrateTo,
): MigrateFuncReturn {
if (!mangaToMigrate.trackRecords) {
throw new Error('TrackRecords of manga to migrate are missing');
}
if (!mangaToMigrateTo.trackRecords) {
throw new Error('TrackRecords of manga to migrate to are missing');
}
const trackBindingsToAdd = mangaToMigrate.trackRecords.nodes.filter((trackRecordToMigrate) =>
mangaToMigrateTo.trackRecords?.nodes.every(
(trackRecord) => trackRecordToMigrate.remoteId !== trackRecord.remoteId,
),
);
return {
copy: () =>
trackBindingsToAdd.map(
(trackRecord) =>
requestManager.bindTracker(mangaToMigrateTo.id, trackRecord.trackerId, trackRecord.remoteId)
.response,
),
cleanup: () =>
mode === 'migrate'
? (mangaToMigrate.trackRecords?.nodes.map(
(trackRecord) => requestManager.unbindTracker(trackRecord.id).response,
) ?? [])
: [],
};
}
private static migrateManga(
mode: MigrateMode,
mangaToMigrateFrom: MangaToMigrate,
mangaToMigrateTo: MangaToMigrateTo,
migrateCategories: boolean,
removeMangaFromCategories: boolean,
): MigrateFuncReturn {
if (!mangaToMigrateFrom?.categories) {
throw new Error('Categories are missing');
}
return {
copy: () => [
requestManager.updateManga(mangaToMigrateTo.id, {
updateManga: { inLibrary: true },
updateMangaCategories: migrateCategories
? {
addToCategories: mangaToMigrateFrom.categories?.nodes.map((category) => category.id),
}
: undefined,
}).response,
],
cleanup: () =>
mode === 'migrate'
? [
requestManager.updateManga(mangaToMigrateFrom.id, {
updateManga: { inLibrary: false },
updateMangaCategories: removeMangaFromCategories ? { clearCategories: true } : undefined,
}).response,
]
: [],
};
}
static async migrate(
mangaId: MangaIdInfo['id'],
mangaIdToMigrateTo: number,
{
mode,
migrateChapters,
migrateCategories,
migrateTracking,
deleteChapters,
}: Omit<MigrateOptions, 'mangaIdToMigrateTo'>,
): Promise<void> {
return Mangas.executeAction('migrate', 1, async () => {
const [{ data: mangaToMigrateData }, { data: mangaToMigrateToData }, { removeMangaFromCategories }] =
await Promise.all([
requestManager.getMangaToMigrate(mangaId, {
migrateChapters,
migrateCategories,
migrateTracking,
deleteChapters,
}).response,
requestManager.getMangaToMigrateToFetch(mangaIdToMigrateTo, {
migrateChapters,
migrateCategories,
migrateTracking,
apolloOptions: { errorPolicy: 'all' },
}).response,
getMetadataServerSettings(),
]);
if (!mangaToMigrateData.manga || !mangaToMigrateToData?.fetchManga?.manga) {
throw new Error('Mangas::migrate: missing manga data');
}
if (migrateChapters && !mangaToMigrateData.manga.chapters) {
throw new Error('Mangas::migrate: missing chapters data');
}
if (!mangaToMigrateToData.fetchChapters?.chapters) {
mangaToMigrateToData.fetchChapters = { chapters: [] };
}
const performMigrationAction = async (
migrateAction: keyof MigrateFuncReturn,
...actions: [boolean | undefined, MigrateFuncReturn][]
) =>
Promise.all(
actions
.filter(([performAction]) => performAction)
.map(([, action]) => action[migrateAction]())
.flat(),
);
const performMigrationActions = async (...actions: [boolean | undefined, MigrateFuncReturn][]) => {
const migrationActions: TupleUnion<keyof MigrateFuncReturn> = ['copy', 'cleanup'];
for (const migrationAction of migrationActions) {
// the migration actions (copy, cleanup) are supposed to be run sequentially to ensure that the cleanup
// only happens in case the copy succeeded
// eslint-disable-next-line no-await-in-loop
await performMigrationAction(migrationAction, ...actions);
}
};
await performMigrationActions(
[migrateChapters, Mangas.migrateChapters(mode, mangaToMigrateData.manga, mangaToMigrateToData)],
[
migrateTracking,
Mangas.migrateTracking(mode, mangaToMigrateData.manga, mangaToMigrateToData.fetchManga.manga),
],
[
true,
Mangas.migrateManga(
mode,
mangaToMigrateData.manga,
mangaToMigrateToData.fetchManga.manga,
!!migrateCategories,
removeMangaFromCategories,
),
],
);
});
}
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[],
{
wasManuallyMarkedAsRead,
changeCategoriesPatch,
mangaIdToMigrateTo,
downloadAhead,
onlyUnread,
size,
...migrateOptions
}: PerformActionOptions<Action>,
): Promise<void> {
switch (action) {
case 'download':
return Mangas.downloadChapters(mangaIds, { downloadAhead, onlyUnread, size });
case 'delete':
return Mangas.deleteChapters(mangaIds);
case 'mark_as_read':
return Mangas.markAsRead(mangaIds, wasManuallyMarkedAsRead!);
case 'mark_as_unread':
return Mangas.markAsUnread(mangaIds);
case 'remove_from_library':
return Mangas.removeFromLibrary(mangaIds);
case 'change_categories':
return Mangas.changeCategories(mangaIds, changeCategoriesPatch!);
case 'migrate': {
return Mangas.migrate(mangaIds[0], mangaIdToMigrateTo!, migrateOptions as unknown as MigrateOptions);
}
default:
throw new Error(`Mangas::performAction: unknown action "${action}"`);
}
}
}