diff --git a/src/modules/manga/components/details/DescriptionGenre.tsx b/src/modules/manga/components/details/DescriptionGenre.tsx new file mode 100644 index 00000000..0edb179e --- /dev/null +++ b/src/modules/manga/components/details/DescriptionGenre.tsx @@ -0,0 +1,106 @@ +/* + * 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 Typography from '@mui/material/Typography'; +import Chip from '@mui/material/Chip'; +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 parseHtml from 'html-react-parser'; +import sanitizeHtml from 'sanitize-html'; +import { useLocalStorage } from '@/modules/core/hooks/useStorage.tsx'; +import { useResizeObserver } from '@/modules/core/hooks/useResizeObserver.tsx'; +import { + MangaDescriptionInfo, + MangaGenreInfo, + MangaLocationState, + MangaSourceIdInfo, +} from '@/modules/manga/Manga.types.ts'; +import { SearchLink } from '@/modules/manga/components/details/SearchLink.tsx'; + +const OPEN_CLOSE_BUTTON_HEIGHT = '35px'; +const DESCRIPTION_COLLAPSED_SIZE = 75; + +export const DescriptionGenre = ({ + manga: { description, genre: mangaGenres, sourceId }, + mode, +}: { + manga: MangaDescriptionInfo & MangaGenreInfo & MangaSourceIdInfo; + mode: MangaLocationState['mode']; +}) => { + const [descriptionElement, setDescriptionElement] = useState(null); + const [descriptionHeight, setDescriptionHeight] = useState(); + 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 && ( + + + + {parseHtml(sanitizeHtml(description, { disallowedTagsMode: 'escape' }))} + + + setIsCollapsed(!isCollapsed)} + sx={{ + justifyContent: 'flex-start', + alignItems: 'center', + cursor: 'pointer', + position: 'absolute', + width: '100%', + height: OPEN_CLOSE_BUTTON_HEIGHT, + bottom: 0, + background: (theme) => + `linear-gradient(transparent -15px, ${theme.palette.background.default})`, + }} + > + (theme.palette.mode === 'light' ? 'black' : 'text') }}> + {isCollapsed ? : } + + + + )} + + {genres.map((genre) => ( + + {}} /> + + ))} + + + ); +}; diff --git a/src/modules/manga/components/MangaDetails.tsx b/src/modules/manga/components/details/MangaDetails.tsx similarity index 52% rename from src/modules/manga/components/MangaDetails.tsx rename to src/modules/manga/components/details/MangaDetails.tsx index b42404d8..fa75d623 100644 --- a/src/modules/manga/components/MangaDetails.tsx +++ b/src/modules/manga/components/details/MangaDetails.tsx @@ -8,28 +8,16 @@ import FavoriteIcon from '@mui/icons-material/Favorite'; import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder'; -import { styled, useTheme } from '@mui/material/styles'; -import { ComponentProps, ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; +import { styled } from '@mui/material/styles'; +import { ComponentProps, ReactNode, useEffect } 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 Chip from '@mui/material/Chip'; 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 ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import { Vibrant } from 'node-vibrant/browser'; -import { FastAverageColor } from 'fast-average-color'; -import parseHtml from 'html-react-parser'; -import sanitizeHtml from 'sanitize-html'; -import { Link as RouterLink } from 'react-router-dom'; import { CustomTooltip } from '@/modules/core/components/CustomTooltip.tsx'; import { makeToast } from '@/modules/core/utils/Toast.ts'; import { Mangas } from '@/modules/manga/services/Mangas.ts'; @@ -40,10 +28,8 @@ import { useManageMangaLibraryState } from '@/modules/manga/hooks/useManageManga import { Metadata as BaseMetadata } from '@/modules/core/components/texts/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 '@/modules/settings/services/ServerSettingsMetadata.ts'; -import { MANGA_COVER_ASPECT_RATIO, MANGA_STATUS_TO_TRANSLATION } from '@/modules/manga/Manga.constants.ts'; +import { MANGA_STATUS_TO_TRANSLATION } from '@/modules/manga/Manga.constants.ts'; import { MangaArtistInfo, MangaAuthorInfo, @@ -58,12 +44,13 @@ import { MangaTitleInfo, MangaTrackRecordInfo, } from '@/modules/manga/Manga.types.ts'; -import { TAppThemeContext, useAppThemeContext } from '@/modules/theme/contexts/AppThemeContext.tsx'; import { applyStyles } from '@/modules/core/utils/ApplyStyles.ts'; import { CustomButtonIcon } from '@/modules/core/components/buttons/CustomButtonIcon.tsx'; import { Sources } from '@/modules/source/services/Sources.ts'; -import { AppRoutes } from '@/modules/core/AppRoute.constants.ts'; import { SourceIdInfo } from '@/modules/source/Source.types.ts'; +import { Thumbnail } from '@/modules/manga/components/details/Thumbnail.tsx'; +import { DescriptionGenre } from '@/modules/manga/components/details/DescriptionGenre.tsx'; +import { SearchLink } from '@/modules/manga/components/details/SearchLink.tsx'; const DetailsWrapper = styled('div')(({ theme }) => ({ display: 'flex', @@ -177,37 +164,6 @@ function getSourceName(source?: Pick | null): return source.displayName ?? source.id; } -const SearchLink = ({ - query, - sourceId, - mode, - children, -}: { - query: string; - sourceId: SourceIdInfo['id'] | undefined; - mode: MangaLocationState['mode'] | 'source.global-search'; - children?: ReactNode; -}) => { - const link = (() => { - const isSourceMode = mode === 'source' && sourceId !== undefined; - if (isSourceMode) { - return AppRoutes.sources.childRoutes.browse.path(sourceId, query); - } - - if (mode === 'source.global-search') { - return AppRoutes.sources.childRoutes.searchAll.path(query); - } - - return AppRoutes.library.path(undefined, query); - })(); - - return ( - - {children ?? query} - - ); -}; - const valuesToJoinedSearchLinks = ( values: string[] | undefined, sourceId: SourceIdInfo['id'] | undefined, @@ -221,209 +177,6 @@ const valuesToJoinedSearchLinks = ( )); -const Thumbnail = ({ - manga, - mangaDynamicColorSchemes, -}: { - manga: Partial; - mangaDynamicColorSchemes: boolean; -}) => { - const theme = useTheme(); - const { setDynamicColor } = useAppThemeContext(); - - const popupState = usePopupState({ variant: 'popover', popupId: 'manga-thumbnail-fullscreen' }); - - const [isImageReady, setIsImageReady] = useState(false); - - useLayoutEffect(() => { - if (!mangaDynamicColorSchemes) { - return () => {}; - } - - const img = new Image(); - img.crossOrigin = 'anonymous'; - img.src = Mangas.getThumbnailUrl(manga); - - img.onload = () => { - const isLargeImage = img.width > 600 && img.height > 600; - - Promise.all([ - Vibrant.from(img).getPalette(), - new FastAverageColor().getColor(img, { - algorithm: 'dominant', - mode: isLargeImage ? 'speed' : 'precision', - ignoredColor: [ - [255, 255, 255, 255, 75], - [0, 0, 0, 255, 75], - ], - }), - ]).then(([palette, averageColor]) => { - if ( - !palette.Vibrant || - !palette.DarkVibrant || - !palette.LightVibrant || - !palette.LightMuted || - !palette.Muted || - !palette.DarkMuted - ) { - return; - } - - setDynamicColor({ - ...palette, - average: averageColor, - } as TAppThemeContext['dynamicColor']); - }); - }; - - return () => { - setDynamicColor(null); - }; - }, []); - - return ( - <> - - setIsImageReady(true)} - imgStyle={{ width: '100%', height: '100%', objectFit: 'cover' }} - /> - {isImageReady && ( - - - - )} - - - popupState.close()} - sx={{ height: '100vh', p: 2, outline: 0, justifyContent: 'center', alignItems: 'center' }} - > - - - - - ); -}; - -const OPEN_CLOSE_BUTTON_HEIGHT = '35px'; -const DESCRIPTION_COLLAPSED_SIZE = 75; -const DescriptionGenre = ({ - manga: { description, genre: mangaGenres, sourceId }, - mode, -}: { - manga: MangaDescriptionInfo & MangaGenreInfo & MangaSourceIdInfo; - mode: MangaLocationState['mode']; -}) => { - const [descriptionElement, setDescriptionElement] = useState(null); - const [descriptionHeight, setDescriptionHeight] = useState(); - 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 && ( - - - - {parseHtml(sanitizeHtml(description, { disallowedTagsMode: 'escape' }))} - - - setIsCollapsed(!isCollapsed)} - sx={{ - justifyContent: 'flex-start', - alignItems: 'center', - cursor: 'pointer', - position: 'absolute', - width: '100%', - height: OPEN_CLOSE_BUTTON_HEIGHT, - bottom: 0, - background: (theme) => - `linear-gradient(transparent -15px, ${theme.palette.background.default})`, - }} - > - (theme.palette.mode === 'light' ? 'black' : 'text') }}> - {isCollapsed ? : } - - - - )} - - {genres.map((genre) => ( - - {}} /> - - ))} - - - ); -}; - export const MangaDetails = ({ manga, mode, diff --git a/src/modules/manga/components/details/SearchLink.tsx b/src/modules/manga/components/details/SearchLink.tsx new file mode 100644 index 00000000..ed9b9bb5 --- /dev/null +++ b/src/modules/manga/components/details/SearchLink.tsx @@ -0,0 +1,45 @@ +/* + * 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 { ReactNode } from 'react'; +import Link from '@mui/material/Link'; +import { Link as RouterLink } from 'react-router-dom'; +import { MangaLocationState } from '@/modules/manga/Manga.types.ts'; +import { SourceIdInfo } from '@/modules/source/Source.types.ts'; +import { AppRoutes } from '@/modules/core/AppRoute.constants.ts'; + +export const SearchLink = ({ + query, + sourceId, + mode, + children, +}: { + query: string; + sourceId: SourceIdInfo['id'] | undefined; + mode: MangaLocationState['mode'] | 'source.global-search'; + children?: ReactNode; +}) => { + const link = (() => { + const isSourceMode = mode === 'source' && sourceId !== undefined; + if (isSourceMode) { + return AppRoutes.sources.childRoutes.browse.path(sourceId, query); + } + + if (mode === 'source.global-search') { + return AppRoutes.sources.childRoutes.searchAll.path(query); + } + + return AppRoutes.library.path(undefined, query); + })(); + + return ( + + {children ?? query} + + ); +}; diff --git a/src/modules/manga/components/details/Thumbnail.tsx b/src/modules/manga/components/details/Thumbnail.tsx new file mode 100644 index 00000000..5ad75218 --- /dev/null +++ b/src/modules/manga/components/details/Thumbnail.tsx @@ -0,0 +1,146 @@ +/* + * 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 { useTheme } from '@mui/material/styles'; +import { useLayoutEffect, useState } from 'react'; +import Stack from '@mui/material/Stack'; +import OpenInFullIcon from '@mui/icons-material/OpenInFull'; +import Modal from '@mui/material/Modal'; +import { bindPopover, bindTrigger, usePopupState } from 'material-ui-popup-state/hooks'; +import { Vibrant } from 'node-vibrant/browser'; +import { FastAverageColor } from 'fast-average-color'; +import { Mangas } from '@/modules/manga/services/Mangas.ts'; +import { SpinnerImage } from '@/modules/core/components/SpinnerImage.tsx'; +import { MANGA_COVER_ASPECT_RATIO } from '@/modules/manga/Manga.constants.ts'; +import { MangaThumbnailInfo } from '@/modules/manga/Manga.types.ts'; +import { TAppThemeContext, useAppThemeContext } from '@/modules/theme/contexts/AppThemeContext.tsx'; + +export const Thumbnail = ({ + manga, + mangaDynamicColorSchemes, +}: { + manga: Partial; + mangaDynamicColorSchemes: boolean; +}) => { + const theme = useTheme(); + const { setDynamicColor } = useAppThemeContext(); + + const popupState = usePopupState({ variant: 'popover', popupId: 'manga-thumbnail-fullscreen' }); + + const [isImageReady, setIsImageReady] = useState(false); + + useLayoutEffect(() => { + if (!mangaDynamicColorSchemes) { + return () => {}; + } + + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.src = Mangas.getThumbnailUrl(manga); + + img.onload = () => { + const isLargeImage = img.width > 600 && img.height > 600; + + Promise.all([ + Vibrant.from(img).getPalette(), + new FastAverageColor().getColor(img, { + algorithm: 'dominant', + mode: isLargeImage ? 'speed' : 'precision', + ignoredColor: [ + [255, 255, 255, 255, 75], + [0, 0, 0, 255, 75], + ], + }), + ]).then(([palette, averageColor]) => { + if ( + !palette.Vibrant || + !palette.DarkVibrant || + !palette.LightVibrant || + !palette.LightMuted || + !palette.Muted || + !palette.DarkMuted + ) { + return; + } + + setDynamicColor({ + ...palette, + average: averageColor, + } as TAppThemeContext['dynamicColor']); + }); + }; + + return () => { + setDynamicColor(null); + }; + }, []); + + return ( + <> + + setIsImageReady(true)} + imgStyle={{ width: '100%', height: '100%', objectFit: 'cover' }} + /> + {isImageReady && ( + + + + )} + + + popupState.close()} + sx={{ height: '100vh', p: 2, outline: 0, justifyContent: 'center', alignItems: 'center' }} + > + + + + + ); +}; diff --git a/src/modules/manga/screens/Manga.tsx b/src/modules/manga/screens/Manga.tsx index 707a6168..23551520 100644 --- a/src/modules/manga/screens/Manga.tsx +++ b/src/modules/manga/screens/Manga.tsx @@ -19,7 +19,7 @@ import { CustomTooltip } from '@/modules/core/components/CustomTooltip.tsx'; import { requestManager } from '@/lib/requests/RequestManager.ts'; 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 { MangaDetails } from '@/modules/manga/components/details/MangaDetails.tsx'; import { MangaToolbarMenu } from '@/modules/manga/components/MangaToolbarMenu.tsx'; import { EmptyViewAbsoluteCentered } from '@/modules/core/components/feedback/EmptyViewAbsoluteCentered.tsx'; import { LoadingPlaceholder } from '@/modules/core/components/feedback/LoadingPlaceholder.tsx';