From 29860482a64ffa3e94994f8f183dd74b83212ee7 Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Sun, 7 Apr 2024 15:33:08 +0200 Subject: [PATCH] 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 --- src/components/MangaCard.tsx | 76 +++++++++--- src/components/manga/MangaActionMenuItems.tsx | 24 ++-- src/components/manga/MangaDetails.tsx | 78 +----------- src/components/manga/MangaToolbarMenu.tsx | 14 ++- .../manga/useManageMangaLibraryState.tsx | 117 ++++++++++++++++++ src/components/molecules/ConfirmDialog.tsx | 62 ++++++++++ .../navbar/action/CategorySelect.tsx | 4 +- .../navbar/action/useCategorySelect.tsx | 43 +++++++ src/components/source/SourceMangaGrid.tsx | 1 + src/i18n/locale/en.json | 6 + src/lib/data/Mangas.ts | 17 +++ src/lib/ui/AwaitableDialog.tsx | 47 +++++++ src/theme.ts | 4 + 13 files changed, 380 insertions(+), 113 deletions(-) create mode 100644 src/components/manga/useManageMangaLibraryState.tsx create mode 100644 src/components/molecules/ConfirmDialog.tsx create mode 100644 src/components/navbar/action/useCategorySelect.tsx create mode 100644 src/lib/ui/AwaitableDialog.tsx diff --git a/src/components/MangaCard.tsx b/src/components/MangaCard.tsx index 5e806abc..abe4933d 100644 --- a/src/components/MangaCard.tsx +++ b/src/components/MangaCard.tsx @@ -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(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', + }, }} > { alt={title} src={thumbnailUrl} imgStyle={ - inLibraryIndicator && inLibrary + inLibraryIndicator && isInLibrary ? { height: '100%', width: '100%', @@ -231,8 +241,35 @@ export const MangaCard = (props: MangaCardProps) => { }} > - {inLibraryIndicator && inLibrary && ( - + {inLibraryIndicator && ( + + )} + {inLibraryIndicator && isInLibrary && ( + {t('manga.button.in_library')} )} @@ -334,6 +371,7 @@ export const MangaCard = (props: MangaCardProps) => { )} )} + {CategorySelectComponent} )} @@ -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) => { - {inLibraryIndicator && inLibrary && ( + {inLibraryIndicator && isInLibrary && ( {t('manga.button.in_library')} @@ -452,6 +491,7 @@ export const MangaCard = (props: MangaCardProps) => { )} )} + {CategorySelectComponent} )} diff --git a/src/components/manga/MangaActionMenuItems.tsx b/src/components/manga/MangaActionMenuItems.tsx index 1061b325..f1e478ef 100644 --- a/src/components/manga/MangaActionMenuItems.tsx +++ b/src/components/manga/MangaActionMenuItems.tsx @@ -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 = ({ )} { - 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 && ( - { - 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 && ( ({ width: '100%', @@ -169,63 +165,13 @@ function getValueOrUnknown(val?: string | null) { export const MangaDetails: React.FC = ({ 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 = ({ manga }) => { @@ -275,20 +220,7 @@ export const MangaDetails: React.FC = ({ manga }) => { - {isCategorySelectOpen && ( - { - setIsCategorySelectOpen(false); - - if (didUpdateCategories) { - addToLibrary(addToCategories, removeFromCategories); - } - }} - mangaId={manga.id} - addToLibrary - /> - )} + {CategorySelectComponent} ); }; diff --git a/src/components/manga/MangaToolbarMenu.tsx b/src/components/manga/MangaToolbarMenu.tsx index 5b99043f..c74edee0 100644 --- a/src/components/manga/MangaToolbarMenu.tsx +++ b/src/components/manga/MangaToolbarMenu.tsx @@ -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) => { { - setEditCategories(true); + openCategorySelect(true); }} >