Feature/browse source manga long press remove add manga from library (#710)

* Extract CategorySelect usage into hook

* Extract logic to change manga in library state into hook

* Change manga in library state on long press when browsing source
This commit is contained in:
schroda
2024-04-07 15:33:08 +02:00
committed by GitHub
parent aab3e25031
commit 29860482a6
13 changed files with 380 additions and 113 deletions

View File

@@ -10,11 +10,12 @@ import Card from '@mui/material/Card';
import CardActionArea from '@mui/material/CardActionArea'; import CardActionArea from '@mui/material/CardActionArea';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { Link as RouterLink } from 'react-router-dom'; import { Link as RouterLink } from 'react-router-dom';
import { Avatar, Box, CardContent, Link, Stack, styled, Tooltip } from '@mui/material'; import { Avatar, Box, Button, CardContent, Link, Stack, styled, Tooltip } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import PopupState, { bindMenu } from 'material-ui-popup-state'; import PopupState, { bindMenu } from 'material-ui-popup-state';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { useLongPress } from 'use-long-press'; import { useLongPress } from 'use-long-press';
import { isMobile } from 'react-device-detect';
import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext'; import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import { SpinnerImage } from '@/components/util/SpinnerImage'; import { SpinnerImage } from '@/components/util/SpinnerImage';
import { TManga, TPartialManga } from '@/typings.ts'; import { TManga, TPartialManga } from '@/typings.ts';
@@ -26,6 +27,7 @@ import { Menu } from '@/components/menu/Menu.tsx';
import { MigrateDialog } from '@/components/MigrateDialog.tsx'; import { MigrateDialog } from '@/components/MigrateDialog.tsx';
import { Mangas } from '@/lib/data/Mangas.ts'; import { Mangas } from '@/lib/data/Mangas.ts';
import { TypographyMaxLines } from '@/components/atoms/TypographyMaxLines.tsx'; import { TypographyMaxLines } from '@/components/atoms/TypographyMaxLines.tsx';
import { useManageMangaLibraryState } from '@/components/manga/useManageMangaLibraryState.tsx';
const BottomGradient = styled('div')({ const BottomGradient = styled('div')({
position: 'absolute', position: 'absolute',
@@ -62,7 +64,7 @@ const BadgeContainer = styled('div')({
}, },
}); });
type MangaCardMode = 'default' | 'migrate.search' | 'migrate.select'; type MangaCardMode = 'default' | 'source' | 'migrate.search' | 'migrate.select';
export interface MangaCardProps { export interface MangaCardProps {
manga: TPartialManga; manga: TPartialManga;
@@ -81,6 +83,7 @@ const getMangaLinkTo = (
): string => { ): string => {
switch (mode) { switch (mode) {
case 'default': case 'default':
case 'source':
return `/manga/${mangaId}/`; return `/manga/${mangaId}/`;
case 'migrate.search': case 'migrate.search':
return `/migrate/source/${sourceId}/manga/${mangaId}/search?query=${mangaTitle}`; return `/migrate/source/${sourceId}/manga/${mangaId}/search?query=${mangaTitle}`;
@@ -97,21 +100,14 @@ export const MangaCard = (props: MangaCardProps) => {
const optionButtonRef = useRef<HTMLButtonElement>(null); const optionButtonRef = useRef<HTMLButtonElement>(null);
const { manga, gridLayout, inLibraryIndicator, selected, handleSelection, mode = 'default' } = props; const { manga, gridLayout, inLibraryIndicator, selected, handleSelection, mode = 'default' } = props;
const { const { id, title, downloadCount, unreadCount: unread, latestReadChapter, firstUnreadChapter, chapters } = manga;
id,
title,
downloadCount,
unreadCount: unread,
inLibrary,
latestReadChapter,
firstUnreadChapter,
chapters,
} = manga;
const thumbnailUrl = Mangas.getThumbnailUrl(manga); const thumbnailUrl = Mangas.getThumbnailUrl(manga);
const { const {
options: { showContinueReadingButton, showUnreadBadge, showDownloadBadge }, options: { showContinueReadingButton, showUnreadBadge, showDownloadBadge },
} = useLibraryOptionsContext(); } = useLibraryOptionsContext();
const { CategorySelectComponent, updateLibraryState, isInLibrary } = useManageMangaLibraryState(manga);
const mangaLinkTo = getMangaLinkTo(mode, manga.id, manga.source?.id, manga.title); const mangaLinkTo = getMangaLinkTo(mode, manga.id, manga.source?.id, manga.title);
const nextChapterIndexToRead = firstUnreadChapter?.sourceOrder ?? 1; const nextChapterIndexToRead = firstUnreadChapter?.sourceOrder ?? 1;
@@ -121,16 +117,24 @@ export const MangaCard = (props: MangaCardProps) => {
const handleClick = (event: React.MouseEvent | React.TouchEvent, openMenu?: () => void) => { const handleClick = (event: React.MouseEvent | React.TouchEvent, openMenu?: () => void) => {
const isDefaultMode = mode === 'default'; const isDefaultMode = mode === 'default';
const isSourceMode = mode === 'source';
const isMigrateSelectMode = mode === 'migrate.select'; const isMigrateSelectMode = mode === 'migrate.select';
const isSelectionMode = selected !== null; const isSelectionMode = selected !== null;
const isLongPress = !!openMenu;
const shouldHandleClick = isMigrateSelectMode || isSelectionMode || (isDefaultMode && !!openMenu); const shouldHandleClick =
isMigrateSelectMode || isSelectionMode || ((isDefaultMode || isSourceMode) && isLongPress);
if (!shouldHandleClick) { if (!shouldHandleClick) {
return; return;
} }
event.preventDefault(); event.preventDefault();
if (isSourceMode) {
updateLibraryState();
return;
}
if (isSelectionMode) { if (isSelectionMode) {
handleSelection?.(id, !selected, { selectRange: event.shiftKey }); handleSelection?.(id, !selected, { selectRange: event.shiftKey });
return; return;
@@ -182,6 +186,12 @@ export const MangaCard = (props: MangaCardProps) => {
pointerEvents: 'all', pointerEvents: 'all',
}, },
}, },
'&:hover .source-manga-library-state-button': {
display: isMobile ? 'none' : 'inline-flex',
},
'&:hover .source-manga-library-state-indicator': {
display: 'none',
},
}} }}
> >
<Card <Card
@@ -201,7 +211,7 @@ export const MangaCard = (props: MangaCardProps) => {
alt={title} alt={title}
src={thumbnailUrl} src={thumbnailUrl}
imgStyle={ imgStyle={
inLibraryIndicator && inLibrary inLibraryIndicator && isInLibrary
? { ? {
height: '100%', height: '100%',
width: '100%', width: '100%',
@@ -231,8 +241,35 @@ export const MangaCard = (props: MangaCardProps) => {
}} }}
> >
<BadgeContainer> <BadgeContainer>
{inLibraryIndicator && inLibrary && ( {inLibraryIndicator && (
<Typography sx={{ backgroundColor: 'primary.dark' }}> <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' }}
>
{t('manga.button.in_library')} {t('manga.button.in_library')}
</Typography> </Typography>
)} )}
@@ -334,6 +371,7 @@ export const MangaCard = (props: MangaCardProps) => {
)} )}
</Menu> </Menu>
)} )}
{CategorySelectComponent}
</> </>
)} )}
</PopupState> </PopupState>
@@ -382,7 +420,8 @@ export const MangaCard = (props: MangaCardProps) => {
width: '100%', width: '100%',
height: '100%', height: '100%',
imageRendering: 'pixelated', imageRendering: 'pixelated',
filter: inLibraryIndicator && inLibrary ? 'brightness(0.4)' : undefined, filter:
inLibraryIndicator && isInLibrary ? 'brightness(0.4)' : undefined,
}} }}
alt={manga.title} alt={manga.title}
src={thumbnailUrl} src={thumbnailUrl}
@@ -402,7 +441,7 @@ export const MangaCard = (props: MangaCardProps) => {
</Box> </Box>
<Stack direction="row" alignItems="center" gap="5px"> <Stack direction="row" alignItems="center" gap="5px">
<BadgeContainer> <BadgeContainer>
{inLibraryIndicator && inLibrary && ( {inLibraryIndicator && isInLibrary && (
<Typography sx={{ backgroundColor: 'primary.dark' }}> <Typography sx={{ backgroundColor: 'primary.dark' }}>
{t('manga.button.in_library')} {t('manga.button.in_library')}
</Typography> </Typography>
@@ -452,6 +491,7 @@ export const MangaCard = (props: MangaCardProps) => {
)} )}
</Menu> </Menu>
)} )}
{CategorySelectComponent}
</> </>
)} )}
</PopupState> </PopupState>

View File

@@ -22,11 +22,11 @@ import { Dialog } from '@mui/material';
import { TManga } from '@/typings.ts'; import { TManga } from '@/typings.ts';
import { actionToTranslationKey, MangaAction, MangaDownloadInfo, Mangas, MangaUnreadInfo } from '@/lib/data/Mangas.ts'; import { actionToTranslationKey, MangaAction, MangaDownloadInfo, Mangas, MangaUnreadInfo } from '@/lib/data/Mangas.ts';
import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts'; import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts';
import { CategorySelect } from '@/components/navbar/action/CategorySelect.tsx';
import { MenuItem } from '@/components/menu/MenuItem.tsx'; import { MenuItem } from '@/components/menu/MenuItem.tsx';
import { createGetMenuItemTitle, createIsMenuItemDisabled, createShouldShowMenuItem } from '@/components/menu/util.ts'; import { createGetMenuItemTitle, createIsMenuItemDisabled, createShouldShowMenuItem } from '@/components/menu/util.ts';
import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts'; import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts';
import { TrackManga } from '@/components/tracker/TrackManga.tsx'; import { TrackManga } from '@/components/tracker/TrackManga.tsx';
import { useCategorySelect } from '@/components/navbar/action/useCategorySelect.tsx';
const ACTION_DISABLES_SELECTION_MODE: MangaAction[] = ['remove_from_library'] as const; const ACTION_DISABLES_SELECTION_MODE: MangaAction[] = ['remove_from_library'] as const;
@@ -54,7 +54,6 @@ export const MangaActionMenuItems = ({
}: Props) => { }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isCategorySelectOpen, setIsCategorySelectOpen] = useState(false);
const [isTrackDialogOpen, setIsTrackDialogOpen] = useState(false); const [isTrackDialogOpen, setIsTrackDialogOpen] = useState(false);
const isSingleMode = !!manga; const isSingleMode = !!manga;
@@ -69,6 +68,13 @@ export const MangaActionMenuItems = ({
const hasUnreadChapters = !!manga?.unreadCount; const hasUnreadChapters = !!manga?.unreadCount;
const hasReadChapters = !!manga && manga.unreadCount !== manga.chapters.totalCount; 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 = () => { const handleSelect = () => {
handleSelection?.(manga.id, true); handleSelection?.(manga.id, true);
onClose(true); onClose(true);
@@ -156,7 +162,7 @@ export const MangaActionMenuItems = ({
)} )}
<MenuItem <MenuItem
onClick={() => { onClick={() => {
setIsCategorySelectOpen(true); openCategorySelect(true);
setHideMenu(true); setHideMenu(true);
}} }}
Icon={Label} Icon={Label}
@@ -167,17 +173,7 @@ export const MangaActionMenuItems = ({
Icon={FavoriteBorderIcon} Icon={FavoriteBorderIcon}
title={getMenuItemTitle('remove_from_library', selectedMangas.length)} title={getMenuItemTitle('remove_from_library', selectedMangas.length)}
/> />
{isCategorySelectOpen && ( {CategorySelectComponent}
<CategorySelect
open={isCategorySelectOpen}
onClose={() => {
setIsCategorySelectOpen(false);
onClose(true);
}}
mangaId={manga?.id as undefined} // either mangaId or mangaIds is undefined, however, ts is not able to infer it correctly and raises an error
mangaIds={(passedSelectedMangas ? Mangas.getIds(selectedMangas) : undefined) as number[]}
/>
)}
{isTrackDialogOpen && ( {isTrackDialogOpen && (
<Dialog <Dialog
open open

View File

@@ -10,21 +10,17 @@ import FavoriteIcon from '@mui/icons-material/Favorite';
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder'; import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
import PublicIcon from '@mui/icons-material/Public'; import PublicIcon from '@mui/icons-material/Public';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { t as translate } from 'i18next'; import { t as translate } from 'i18next';
import { Link } from '@mui/material'; import { Link } from '@mui/material';
import { ISource, TManga } from '@/typings'; import { ISource, TManga } from '@/typings';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { makeToast } from '@/components/util/Toast'; import { makeToast } from '@/components/util/Toast';
import { useMetadataServerSettings } from '@/lib/metadata/metadataServerSettings.ts';
import { CategorySelect } from '@/components/navbar/action/CategorySelect.tsx';
import { Mangas } from '@/lib/data/Mangas.ts'; import { Mangas } from '@/lib/data/Mangas.ts';
import { SpinnerImage } from '@/components/util/SpinnerImage.tsx'; import { SpinnerImage } from '@/components/util/SpinnerImage.tsx';
import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts';
import { Categories } from '@/lib/data/Categories.ts';
import { CustomIconButton } from '@/components/atoms/CustomIconButton'; import { CustomIconButton } from '@/components/atoms/CustomIconButton';
import { TrackMangaButton } from '@/components/manga/TrackMangaButton.tsx'; import { TrackMangaButton } from '@/components/manga/TrackMangaButton.tsx';
import { useManageMangaLibraryState } from '@/components/manga/useManageMangaLibraryState.tsx';
const DetailsWrapper = styled('div')(({ theme }) => ({ const DetailsWrapper = styled('div')(({ theme }) => ({
width: '100%', width: '100%',
@@ -169,63 +165,13 @@ function getValueOrUnknown(val?: string | null) {
export const MangaDetails: React.FC<IProps> = ({ manga }) => { export const MangaDetails: React.FC<IProps> = ({ manga }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const {
settings: { showAddToLibraryCategorySelectDialog },
loading: areSettingsLoading,
} = useMetadataServerSettings();
const categories = requestManager.useGetCategories();
const userCreatedCategories = useMemo(
() => Categories.getUserCreated(categories.data?.categories.nodes ?? []),
[categories.data?.categories.nodes],
);
const [isCategorySelectOpen, setIsCategorySelectOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (!manga.source) { if (!manga.source) {
makeToast(translate('source.error.label.source_not_found'), 'error'); makeToast(translate('source.error.label.source_not_found'), 'error');
} }
}, [manga.source]); }, [manga.source]);
const addToLibrary = (addToCategories: number[] = [], removeFromCategories: number[] = []) => { const { CategorySelectComponent, updateLibraryState } = useManageMangaLibraryState(manga);
requestManager
.updateManga(manga.id, {
updateManga: { inLibrary: true },
updateMangaCategories: { addToCategories, removeFromCategories },
})
.response.then(() => makeToast(t('library.info.label.added_to_library'), 'success'))
.catch(() => {
makeToast(t('library.error.label.add_to_library'), 'error');
});
};
const handleAddToLibraryClick = () => {
if (categories.loading) {
makeToast(t('global.label.load_in_progress'), 'info');
return;
}
if (categories.error) {
makeToast(t('category.error.label.request_failure'), 'error');
categories
.refetch()
.catch(defaultPromiseErrorHandler('MangaDetails::handleAddToLibraryClick: refetch categories'));
return;
}
const showCategorySelectDialog = showAddToLibraryCategorySelectDialog && !!userCreatedCategories.length;
if (!showCategorySelectDialog) {
addToLibrary(Categories.getIds(Categories.getDefaults(userCreatedCategories!)));
return;
}
setIsCategorySelectOpen(true);
};
const removeFromLibrary = () => {
Mangas.removeFromLibrary([manga.id]).catch(defaultPromiseErrorHandler('MangaDetails::removeFromLibrary'));
};
return ( return (
<> <>
@@ -251,8 +197,7 @@ export const MangaDetails: React.FC<IProps> = ({ manga }) => {
</ThumbnailMetadataWrapper> </ThumbnailMetadataWrapper>
<MangaButtonsContainer> <MangaButtonsContainer>
<CustomIconButton <CustomIconButton
disabled={areSettingsLoading || categories.loading} onClick={updateLibraryState}
onClick={manga.inLibrary ? removeFromLibrary : handleAddToLibraryClick}
size="large" size="large"
sx={{ color: manga.inLibrary ? '#2196f3' : 'inherit' }} sx={{ color: manga.inLibrary ? '#2196f3' : 'inherit' }}
> >
@@ -275,20 +220,7 @@ export const MangaDetails: React.FC<IProps> = ({ manga }) => {
</Genres> </Genres>
</BottomContentWrapper> </BottomContentWrapper>
</DetailsWrapper> </DetailsWrapper>
{isCategorySelectOpen && ( {CategorySelectComponent}
<CategorySelect
open={isCategorySelectOpen}
onClose={(didUpdateCategories, addToCategories, removeFromCategories) => {
setIsCategorySelectOpen(false);
if (didUpdateCategories) {
addToLibrary(addToCategories, removeFromCategories);
}
}}
mangaId={manga.id}
addToLibrary
/>
)}
</> </>
); );
}; };

View File

@@ -19,12 +19,12 @@ import {
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from '@mui/material'; } from '@mui/material';
import React, { useState } from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import SyncAltIcon from '@mui/icons-material/SyncAlt'; import SyncAltIcon from '@mui/icons-material/SyncAlt';
import { CategorySelect } from '@/components/navbar/action/CategorySelect';
import { TManga } from '@/typings.ts'; import { TManga } from '@/typings.ts';
import { useCategorySelect } from '@/components/navbar/action/useCategorySelect.tsx';
interface IProps { interface IProps {
manga: TManga; manga: TManga;
@@ -44,7 +44,9 @@ export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
setAnchorEl(null); setAnchorEl(null);
}; };
const [editCategories, setEditCategories] = useState(false); const { openCategorySelect, CategorySelectComponent } = useCategorySelect({
mangaId: manga.id,
});
return ( return (
<> <>
@@ -76,7 +78,7 @@ export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
<Tooltip title={t('manga.label.edit_categories')}> <Tooltip title={t('manga.label.edit_categories')}>
<IconButton <IconButton
onClick={() => { onClick={() => {
setEditCategories(true); openCategorySelect(true);
}} }}
> >
<Label /> <Label />
@@ -134,7 +136,7 @@ export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
<MenuItem <MenuItem
key="categories" key="categories"
onClick={() => { onClick={() => {
setEditCategories(true); openCategorySelect(true);
handleClose(); handleClose();
}} }}
> >
@@ -148,7 +150,7 @@ export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
</> </>
)} )}
<CategorySelect open={editCategories} onClose={() => setEditCategories(false)} mangaId={manga.id} /> {CategorySelectComponent}
</> </>
); );
}; };

View File

@@ -0,0 +1,117 @@
/*
* 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, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useCategorySelect } from '@/components/navbar/action/useCategorySelect.tsx';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { makeToast } from '@/components/util/Toast.tsx';
import { useMetadataServerSettings } from '@/lib/metadata/metadataServerSettings.ts';
import { Categories } from '@/lib/data/Categories.ts';
import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts';
import { Mangas } from '@/lib/data/Mangas.ts';
import { TManga } from '@/typings.ts';
import { awaitConfirmation } from '@/lib/ui/AwaitableDialog.tsx';
export const useManageMangaLibraryState = (manga: Pick<TManga, 'id' | 'title' | 'inLibrary'>) => {
const { t } = useTranslation();
const [isInLibrary, setIsInLibrary] = useState(manga.inLibrary);
const {
settings: { showAddToLibraryCategorySelectDialog },
loading: areSettingsLoading,
} = useMetadataServerSettings();
const categories = requestManager.useGetCategories();
const userCreatedCategories = useMemo(
() => Categories.getUserCreated(categories.data?.categories.nodes ?? []),
[categories.data?.categories.nodes],
);
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 () => {
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]);
const { openCategorySelect, CategorySelectComponent } = useCategorySelect({
mangaId: manga.id,
addToLibrary: true,
onClose: addToLibrary,
});
const updateLibraryState = useCallback(() => {
if (isInLibrary) {
removeFromLibrary().catch(
defaultPromiseErrorHandler('useManageMangaLibraryState::updateLibraryState::removeFromLibrary'),
);
return;
}
if (areSettingsLoading || categories.loading) {
makeToast(t('global.label.load_in_progress'), 'info');
return;
}
if (categories.error) {
makeToast(t('category.error.label.request_failure'), 'error');
categories
.refetch()
.catch(defaultPromiseErrorHandler('MangaDetails::handleAddToLibraryClick: refetch categories'));
return;
}
const showCategorySelectDialog = showAddToLibraryCategorySelectDialog && !!userCreatedCategories.length;
if (!showCategorySelectDialog) {
addToLibrary(true, Categories.getIds(Categories.getDefaults(userCreatedCategories!)));
return;
}
openCategorySelect(true);
}, [isInLibrary, removeFromLibrary, addToLibrary, areSettingsLoading, categories.loading]);
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<TManga>(manga.id)?.inLibrary ?? isInLibrary,
};
};

View File

@@ -0,0 +1,62 @@
/*
* 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 Dialog from '@mui/material/Dialog/Dialog';
import DialogActions from '@mui/material/DialogActions/DialogActions';
import DialogContent from '@mui/material/DialogContent/DialogContent';
import DialogTitle from '@mui/material/DialogTitle/DialogTitle';
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button/Button';
type Action = {
show?: boolean;
title?: string;
};
type Actions = {
cancel?: Action;
confirm?: Action;
};
export const ConfirmDialog = ({
title,
message,
actions: passedActions,
onCancel,
onConfirm,
}: {
title: string;
message: string;
actions?: Actions;
onCancel: () => void;
onConfirm: () => void;
}) => {
const { t } = useTranslation();
const actions = {
cancel: {
show: passedActions?.cancel?.show ?? true,
title: passedActions?.cancel?.title ?? t('global.button.cancel'),
},
confirm: {
show: passedActions?.confirm?.show ?? true,
title: passedActions?.confirm?.title ?? t('global.button.ok'),
},
} satisfies Actions;
return (
<Dialog open>
<DialogTitle>{title}</DialogTitle>
<DialogContent>{message}</DialogContent>
<DialogActions>
{actions.cancel.show && <Button onClick={onCancel}>{actions.cancel.title}</Button>}
{actions.confirm.show && <Button onClick={onConfirm}>{actions.confirm.title}</Button>}
</DialogActions>
</Dialog>
);
};

View File

@@ -40,7 +40,7 @@ type MultiMangaModeProps = {
mangaIds: number[]; mangaIds: number[];
}; };
type Props = export type CategorySelectProps =
| (BaseProps & SingleMangaModeProps & PropertiesNever<MultiMangaModeProps>) | (BaseProps & SingleMangaModeProps & PropertiesNever<MultiMangaModeProps>)
| (BaseProps & PropertiesNever<SingleMangaModeProps> & MultiMangaModeProps); | (BaseProps & PropertiesNever<SingleMangaModeProps> & MultiMangaModeProps);
@@ -77,7 +77,7 @@ const getCategoryCheckedState = (
return undefined; return undefined;
}; };
export function CategorySelect(props: Props) { export function CategorySelect(props: CategorySelectProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { open, onClose, mangaId, mangaIds: passedMangaIds, addToLibrary = false } = props; const { open, onClose, mangaId, mangaIds: passedMangaIds, addToLibrary = false } = props;

View File

@@ -0,0 +1,43 @@
/*
* 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 { useMemo, useState } from 'react';
import { CategorySelect, CategorySelectProps } from '@/components/navbar/action/CategorySelect.tsx';
export const useCategorySelect = ({
mangaId,
mangaIds,
onClose,
addToLibrary,
}: Omit<CategorySelectProps, 'open' | 'onClose'> & Pick<Partial<CategorySelectProps>, 'onClose'>) => {
const [isCategorySelectOpen, setIsCategorySelectOpen] = useState(false);
const CategorySelectComponent = useMemo(() => {
if (!isCategorySelectOpen) {
return null;
}
return (
<CategorySelect
open={isCategorySelectOpen}
onClose={(...args) => {
setIsCategorySelectOpen(false);
onClose?.(...args);
}}
mangaId={mangaId!} // either mangaId or mangaIds is undefined, however, ts is not able to infer it correctly and raises an error
mangaIds={mangaIds as undefined}
addToLibrary={addToLibrary}
/>
);
}, [mangaId, mangaIds, addToLibrary, onClose, isCategorySelectOpen]);
return {
openCategorySelect: setIsCategorySelectOpen,
CategorySelectComponent,
};
};

View File

@@ -31,6 +31,7 @@ export function SourceMangaGrid(props: IMangaGridProps) {
messageExtra={messageExtra} messageExtra={messageExtra}
gridLayout={gridLayout} gridLayout={gridLayout}
inLibraryIndicator inLibraryIndicator
mode="source"
/> />
); );
} }

View File

@@ -368,6 +368,7 @@
}, },
"label": { "label": {
"advanced": "Advanced", "advanced": "Advanced",
"are_you_sure": "Are you sure?",
"browse": "Browse", "browse": "Browse",
"client": "Client", "client": "Client",
"close": "Close", "close": "Close",
@@ -580,6 +581,11 @@
"button": { "button": {
"selected": "Remove selected from the library" "selected": "Remove selected from the library"
}, },
"dialog": {
"label": {
"message": "You are about to remove \"{{title}}\" from your library"
}
},
"label": { "label": {
"action": "Remove from the library", "action": "Remove from the library",
"error_one": "Could not remove manga from the library", "error_one": "Could not remove manga from the library",

View File

@@ -7,6 +7,7 @@
*/ */
import { t as translate } from 'i18next'; import { t as translate } from 'i18next';
import { DocumentNode } from '@apollo/client/core';
import { TManga, TranslationKey } from '@/typings.ts'; import { TManga, TranslationKey } from '@/typings.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts';
import { import {
@@ -19,6 +20,7 @@ import {
import { Chapters } from '@/lib/data/Chapters.ts'; import { Chapters } from '@/lib/data/Chapters.ts';
import { makeToast } from '@/components/util/Toast.tsx'; import { makeToast } from '@/components/util/Toast.tsx';
import { getMetadataServerSettings } from '@/lib/metadata/metadataServerSettings.ts'; import { getMetadataServerSettings } from '@/lib/metadata/metadataServerSettings.ts';
import { FULL_MANGA_FIELDS } from '@/lib/graphql/Fragments.ts';
export type MangaAction = export type MangaAction =
| 'download' | 'download'
@@ -135,6 +137,21 @@ export class Mangas {
return mangas.map((manga) => manga.id); return mangas.map((manga) => manga.id);
} }
static getFromCache<T>(
id: number,
fragment: DocumentNode = FULL_MANGA_FIELDS,
fragmentName: string = 'FULL_MANGA_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 { static isNotDownloaded({ downloadCount }: MangaDownloadInfo): boolean {
return downloadCount === 0; return downloadCount === 0;
} }

View File

@@ -0,0 +1,47 @@
/*
* 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 { createRoot } from 'react-dom/client';
import ThemeProvider from '@mui/material/styles/ThemeProvider';
import { ConfirmDialog } from '@/components/molecules/ConfirmDialog.tsx';
import { ControlledPromise } from '@/lib/ControlledPromise.ts';
import { getCurrentTheme } from '@/theme.ts';
export const awaitConfirmation = async (
dialogProps: Omit<React.ComponentProps<typeof ConfirmDialog>, 'onCancel' | 'onConfirm'>,
) => {
const dialogContainer = document.createElement('div');
document.body.appendChild(dialogContainer);
const root = createRoot(dialogContainer);
const confirmationPromise = new ControlledPromise();
const handleConfirmation = (accepted: boolean) => {
if (accepted) {
confirmationPromise.resolve();
} else {
confirmationPromise.reject();
}
root.unmount();
document.body.removeChild(dialogContainer);
};
root.render(
<ThemeProvider theme={getCurrentTheme()}>
<ConfirmDialog
{...dialogProps}
onCancel={() => handleConfirmation(false)}
onConfirm={() => handleConfirmation(true)}
/>
,
</ThemeProvider>,
);
return confirmationPromise.promise;
};

View File

@@ -21,6 +21,8 @@ declare module '@mui/material/styles/createPalette' {
type DefaultMuiPalette = Omit<MuiPalette, 'custom'>; type DefaultMuiPalette = Omit<MuiPalette, 'custom'>;
type DefaultMuiTheme = Omit<Theme, 'palette'> & { palette: DefaultMuiPalette }; type DefaultMuiTheme = Omit<Theme, 'palette'> & { palette: DefaultMuiPalette };
let theme: Theme;
export const getCurrentTheme = () => theme;
export const createTheme = (dark?: boolean) => { export const createTheme = (dark?: boolean) => {
const baseTheme: DefaultMuiTheme = createMuiTheme({ const baseTheme: DefaultMuiTheme = createMuiTheme({
palette: { palette: {
@@ -60,5 +62,7 @@ export const createTheme = (dark?: boolean) => {
baseTheme, baseTheme,
); );
theme = suwayomiTheme;
return suwayomiTheme; return suwayomiTheme;
}; };