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:
@@ -10,11 +10,12 @@ import Card from '@mui/material/Card';
|
||||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import Typography from '@mui/material/Typography';
|
||||
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 PopupState, { bindMenu } from 'material-ui-popup-state';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useLongPress } from 'use-long-press';
|
||||
import { isMobile } from 'react-device-detect';
|
||||
import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
|
||||
import { SpinnerImage } from '@/components/util/SpinnerImage';
|
||||
import { TManga, TPartialManga } from '@/typings.ts';
|
||||
@@ -26,6 +27,7 @@ import { Menu } from '@/components/menu/Menu.tsx';
|
||||
import { MigrateDialog } from '@/components/MigrateDialog.tsx';
|
||||
import { Mangas } from '@/lib/data/Mangas.ts';
|
||||
import { TypographyMaxLines } from '@/components/atoms/TypographyMaxLines.tsx';
|
||||
import { useManageMangaLibraryState } from '@/components/manga/useManageMangaLibraryState.tsx';
|
||||
|
||||
const BottomGradient = styled('div')({
|
||||
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 {
|
||||
manga: TPartialManga;
|
||||
@@ -81,6 +83,7 @@ const getMangaLinkTo = (
|
||||
): string => {
|
||||
switch (mode) {
|
||||
case 'default':
|
||||
case 'source':
|
||||
return `/manga/${mangaId}/`;
|
||||
case 'migrate.search':
|
||||
return `/migrate/source/${sourceId}/manga/${mangaId}/search?query=${mangaTitle}`;
|
||||
@@ -97,21 +100,14 @@ export const MangaCard = (props: MangaCardProps) => {
|
||||
const optionButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const { manga, gridLayout, inLibraryIndicator, selected, handleSelection, mode = 'default' } = props;
|
||||
const {
|
||||
id,
|
||||
title,
|
||||
downloadCount,
|
||||
unreadCount: unread,
|
||||
inLibrary,
|
||||
latestReadChapter,
|
||||
firstUnreadChapter,
|
||||
chapters,
|
||||
} = manga;
|
||||
const { id, title, downloadCount, unreadCount: unread, latestReadChapter, firstUnreadChapter, chapters } = manga;
|
||||
const thumbnailUrl = Mangas.getThumbnailUrl(manga);
|
||||
const {
|
||||
options: { showContinueReadingButton, showUnreadBadge, showDownloadBadge },
|
||||
} = useLibraryOptionsContext();
|
||||
|
||||
const { CategorySelectComponent, updateLibraryState, isInLibrary } = useManageMangaLibraryState(manga);
|
||||
|
||||
const mangaLinkTo = getMangaLinkTo(mode, manga.id, manga.source?.id, manga.title);
|
||||
|
||||
const nextChapterIndexToRead = firstUnreadChapter?.sourceOrder ?? 1;
|
||||
@@ -121,16 +117,24 @@ export const MangaCard = (props: MangaCardProps) => {
|
||||
|
||||
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 && !!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;
|
||||
@@ -182,6 +186,12 @@ export const MangaCard = (props: MangaCardProps) => {
|
||||
pointerEvents: 'all',
|
||||
},
|
||||
},
|
||||
'&:hover .source-manga-library-state-button': {
|
||||
display: isMobile ? 'none' : 'inline-flex',
|
||||
},
|
||||
'&:hover .source-manga-library-state-indicator': {
|
||||
display: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
@@ -201,7 +211,7 @@ export const MangaCard = (props: MangaCardProps) => {
|
||||
alt={title}
|
||||
src={thumbnailUrl}
|
||||
imgStyle={
|
||||
inLibraryIndicator && inLibrary
|
||||
inLibraryIndicator && isInLibrary
|
||||
? {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
@@ -231,8 +241,35 @@ export const MangaCard = (props: MangaCardProps) => {
|
||||
}}
|
||||
>
|
||||
<BadgeContainer>
|
||||
{inLibraryIndicator && inLibrary && (
|
||||
<Typography sx={{ backgroundColor: 'primary.dark' }}>
|
||||
{inLibraryIndicator && (
|
||||
<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')}
|
||||
</Typography>
|
||||
)}
|
||||
@@ -334,6 +371,7 @@ export const MangaCard = (props: MangaCardProps) => {
|
||||
)}
|
||||
</Menu>
|
||||
)}
|
||||
{CategorySelectComponent}
|
||||
</>
|
||||
)}
|
||||
</PopupState>
|
||||
@@ -382,7 +420,8 @@ export const MangaCard = (props: MangaCardProps) => {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
imageRendering: 'pixelated',
|
||||
filter: inLibraryIndicator && inLibrary ? 'brightness(0.4)' : undefined,
|
||||
filter:
|
||||
inLibraryIndicator && isInLibrary ? 'brightness(0.4)' : undefined,
|
||||
}}
|
||||
alt={manga.title}
|
||||
src={thumbnailUrl}
|
||||
@@ -402,7 +441,7 @@ export const MangaCard = (props: MangaCardProps) => {
|
||||
</Box>
|
||||
<Stack direction="row" alignItems="center" gap="5px">
|
||||
<BadgeContainer>
|
||||
{inLibraryIndicator && inLibrary && (
|
||||
{inLibraryIndicator && isInLibrary && (
|
||||
<Typography sx={{ backgroundColor: 'primary.dark' }}>
|
||||
{t('manga.button.in_library')}
|
||||
</Typography>
|
||||
@@ -452,6 +491,7 @@ export const MangaCard = (props: MangaCardProps) => {
|
||||
)}
|
||||
</Menu>
|
||||
)}
|
||||
{CategorySelectComponent}
|
||||
</>
|
||||
)}
|
||||
</PopupState>
|
||||
|
||||
@@ -22,11 +22,11 @@ import { Dialog } from '@mui/material';
|
||||
import { TManga } from '@/typings.ts';
|
||||
import { actionToTranslationKey, MangaAction, MangaDownloadInfo, Mangas, MangaUnreadInfo } from '@/lib/data/Mangas.ts';
|
||||
import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts';
|
||||
import { CategorySelect } from '@/components/navbar/action/CategorySelect.tsx';
|
||||
import { MenuItem } from '@/components/menu/MenuItem.tsx';
|
||||
import { createGetMenuItemTitle, createIsMenuItemDisabled, createShouldShowMenuItem } from '@/components/menu/util.ts';
|
||||
import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts';
|
||||
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;
|
||||
|
||||
@@ -54,7 +54,6 @@ export const MangaActionMenuItems = ({
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isCategorySelectOpen, setIsCategorySelectOpen] = useState(false);
|
||||
const [isTrackDialogOpen, setIsTrackDialogOpen] = useState(false);
|
||||
|
||||
const isSingleMode = !!manga;
|
||||
@@ -69,6 +68,13 @@ export const MangaActionMenuItems = ({
|
||||
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);
|
||||
@@ -156,7 +162,7 @@ export const MangaActionMenuItems = ({
|
||||
)}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setIsCategorySelectOpen(true);
|
||||
openCategorySelect(true);
|
||||
setHideMenu(true);
|
||||
}}
|
||||
Icon={Label}
|
||||
@@ -167,17 +173,7 @@ export const MangaActionMenuItems = ({
|
||||
Icon={FavoriteBorderIcon}
|
||||
title={getMenuItemTitle('remove_from_library', selectedMangas.length)}
|
||||
/>
|
||||
{isCategorySelectOpen && (
|
||||
<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[]}
|
||||
/>
|
||||
)}
|
||||
{CategorySelectComponent}
|
||||
{isTrackDialogOpen && (
|
||||
<Dialog
|
||||
open
|
||||
|
||||
@@ -10,21 +10,17 @@ import FavoriteIcon from '@mui/icons-material/Favorite';
|
||||
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
|
||||
import PublicIcon from '@mui/icons-material/Public';
|
||||
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 { t as translate } from 'i18next';
|
||||
import { Link } from '@mui/material';
|
||||
import { ISource, TManga } from '@/typings';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
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 { 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 { TrackMangaButton } from '@/components/manga/TrackMangaButton.tsx';
|
||||
import { useManageMangaLibraryState } from '@/components/manga/useManageMangaLibraryState.tsx';
|
||||
|
||||
const DetailsWrapper = styled('div')(({ theme }) => ({
|
||||
width: '100%',
|
||||
@@ -169,63 +165,13 @@ function getValueOrUnknown(val?: string | null) {
|
||||
export const MangaDetails: React.FC<IProps> = ({ manga }) => {
|
||||
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(() => {
|
||||
if (!manga.source) {
|
||||
makeToast(translate('source.error.label.source_not_found'), 'error');
|
||||
}
|
||||
}, [manga.source]);
|
||||
|
||||
const addToLibrary = (addToCategories: number[] = [], removeFromCategories: number[] = []) => {
|
||||
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'));
|
||||
};
|
||||
const { CategorySelectComponent, updateLibraryState } = useManageMangaLibraryState(manga);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -251,8 +197,7 @@ export const MangaDetails: React.FC<IProps> = ({ manga }) => {
|
||||
</ThumbnailMetadataWrapper>
|
||||
<MangaButtonsContainer>
|
||||
<CustomIconButton
|
||||
disabled={areSettingsLoading || categories.loading}
|
||||
onClick={manga.inLibrary ? removeFromLibrary : handleAddToLibraryClick}
|
||||
onClick={updateLibraryState}
|
||||
size="large"
|
||||
sx={{ color: manga.inLibrary ? '#2196f3' : 'inherit' }}
|
||||
>
|
||||
@@ -275,20 +220,7 @@ export const MangaDetails: React.FC<IProps> = ({ manga }) => {
|
||||
</Genres>
|
||||
</BottomContentWrapper>
|
||||
</DetailsWrapper>
|
||||
{isCategorySelectOpen && (
|
||||
<CategorySelect
|
||||
open={isCategorySelectOpen}
|
||||
onClose={(didUpdateCategories, addToCategories, removeFromCategories) => {
|
||||
setIsCategorySelectOpen(false);
|
||||
|
||||
if (didUpdateCategories) {
|
||||
addToLibrary(addToCategories, removeFromCategories);
|
||||
}
|
||||
}}
|
||||
mangaId={manga.id}
|
||||
addToLibrary
|
||||
/>
|
||||
)}
|
||||
{CategorySelectComponent}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,12 +19,12 @@ import {
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import SyncAltIcon from '@mui/icons-material/SyncAlt';
|
||||
import { CategorySelect } from '@/components/navbar/action/CategorySelect';
|
||||
import { TManga } from '@/typings.ts';
|
||||
import { useCategorySelect } from '@/components/navbar/action/useCategorySelect.tsx';
|
||||
|
||||
interface IProps {
|
||||
manga: TManga;
|
||||
@@ -44,7 +44,9 @@ export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const [editCategories, setEditCategories] = useState(false);
|
||||
const { openCategorySelect, CategorySelectComponent } = useCategorySelect({
|
||||
mangaId: manga.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -76,7 +78,7 @@ export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
|
||||
<Tooltip title={t('manga.label.edit_categories')}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setEditCategories(true);
|
||||
openCategorySelect(true);
|
||||
}}
|
||||
>
|
||||
<Label />
|
||||
@@ -134,7 +136,7 @@ export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
|
||||
<MenuItem
|
||||
key="categories"
|
||||
onClick={() => {
|
||||
setEditCategories(true);
|
||||
openCategorySelect(true);
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
@@ -148,7 +150,7 @@ export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<CategorySelect open={editCategories} onClose={() => setEditCategories(false)} mangaId={manga.id} />
|
||||
{CategorySelectComponent}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
117
src/components/manga/useManageMangaLibraryState.tsx
Normal file
117
src/components/manga/useManageMangaLibraryState.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
62
src/components/molecules/ConfirmDialog.tsx
Normal file
62
src/components/molecules/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -40,7 +40,7 @@ type MultiMangaModeProps = {
|
||||
mangaIds: number[];
|
||||
};
|
||||
|
||||
type Props =
|
||||
export type CategorySelectProps =
|
||||
| (BaseProps & SingleMangaModeProps & PropertiesNever<MultiMangaModeProps>)
|
||||
| (BaseProps & PropertiesNever<SingleMangaModeProps> & MultiMangaModeProps);
|
||||
|
||||
@@ -77,7 +77,7 @@ const getCategoryCheckedState = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export function CategorySelect(props: Props) {
|
||||
export function CategorySelect(props: CategorySelectProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { open, onClose, mangaId, mangaIds: passedMangaIds, addToLibrary = false } = props;
|
||||
|
||||
43
src/components/navbar/action/useCategorySelect.tsx
Normal file
43
src/components/navbar/action/useCategorySelect.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -31,6 +31,7 @@ export function SourceMangaGrid(props: IMangaGridProps) {
|
||||
messageExtra={messageExtra}
|
||||
gridLayout={gridLayout}
|
||||
inLibraryIndicator
|
||||
mode="source"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
},
|
||||
"label": {
|
||||
"advanced": "Advanced",
|
||||
"are_you_sure": "Are you sure?",
|
||||
"browse": "Browse",
|
||||
"client": "Client",
|
||||
"close": "Close",
|
||||
@@ -580,6 +581,11 @@
|
||||
"button": {
|
||||
"selected": "Remove selected from the library"
|
||||
},
|
||||
"dialog": {
|
||||
"label": {
|
||||
"message": "You are about to remove \"{{title}}\" from your library"
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"action": "Remove from the library",
|
||||
"error_one": "Could not remove manga from the library",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { t as translate } from 'i18next';
|
||||
import { DocumentNode } from '@apollo/client/core';
|
||||
import { TManga, TranslationKey } from '@/typings.ts';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import {
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
import { Chapters } from '@/lib/data/Chapters.ts';
|
||||
import { makeToast } from '@/components/util/Toast.tsx';
|
||||
import { getMetadataServerSettings } from '@/lib/metadata/metadataServerSettings.ts';
|
||||
import { FULL_MANGA_FIELDS } from '@/lib/graphql/Fragments.ts';
|
||||
|
||||
export type MangaAction =
|
||||
| 'download'
|
||||
@@ -135,6 +137,21 @@ export class Mangas {
|
||||
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 {
|
||||
return downloadCount === 0;
|
||||
}
|
||||
|
||||
47
src/lib/ui/AwaitableDialog.tsx
Normal file
47
src/lib/ui/AwaitableDialog.tsx
Normal 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;
|
||||
};
|
||||
@@ -21,6 +21,8 @@ declare module '@mui/material/styles/createPalette' {
|
||||
type DefaultMuiPalette = Omit<MuiPalette, 'custom'>;
|
||||
type DefaultMuiTheme = Omit<Theme, 'palette'> & { palette: DefaultMuiPalette };
|
||||
|
||||
let theme: Theme;
|
||||
export const getCurrentTheme = () => theme;
|
||||
export const createTheme = (dark?: boolean) => {
|
||||
const baseTheme: DefaultMuiTheme = createMuiTheme({
|
||||
palette: {
|
||||
@@ -60,5 +62,7 @@ export const createTheme = (dark?: boolean) => {
|
||||
baseTheme,
|
||||
);
|
||||
|
||||
theme = suwayomiTheme;
|
||||
|
||||
return suwayomiTheme;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user