Extract components from "MangaDetails"
This commit is contained in:
106
src/modules/manga/components/details/DescriptionGenre.tsx
Normal file
106
src/modules/manga/components/details/DescriptionGenre.tsx
Normal file
@@ -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<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}
|
||||||
|
sx={{
|
||||||
|
whiteSpace: 'pre-line',
|
||||||
|
textAlign: 'justify',
|
||||||
|
textJustify: 'inter-word',
|
||||||
|
mb: OPEN_CLOSE_BUTTON_HEIGHT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parseHtml(sanitizeHtml(description, { disallowedTagsMode: 'escape' }))}
|
||||||
|
</Typography>
|
||||||
|
</Collapse>
|
||||||
|
<Stack
|
||||||
|
onClick={() => 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})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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) => (
|
||||||
|
<SearchLink key={genre} query={genre} sourceId={sourceId} mode={mode}>
|
||||||
|
<Chip label={genre} variant="outlined" onClick={() => {}} />
|
||||||
|
</SearchLink>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,28 +8,16 @@
|
|||||||
|
|
||||||
import FavoriteIcon from '@mui/icons-material/Favorite';
|
import FavoriteIcon from '@mui/icons-material/Favorite';
|
||||||
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
|
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
|
||||||
import { styled, useTheme } from '@mui/material/styles';
|
import { styled } from '@mui/material/styles';
|
||||||
import { ComponentProps, ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
|
import { ComponentProps, ReactNode, useEffect } 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/Link';
|
import Link from '@mui/material/Link';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import Chip from '@mui/material/Chip';
|
|
||||||
import LaunchIcon from '@mui/icons-material/Launch';
|
import LaunchIcon from '@mui/icons-material/Launch';
|
||||||
import Collapse from '@mui/material/Collapse';
|
|
||||||
import Stack from '@mui/material/Stack';
|
import Stack from '@mui/material/Stack';
|
||||||
import IconButton from '@mui/material/IconButton';
|
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 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 { CustomTooltip } from '@/modules/core/components/CustomTooltip.tsx';
|
||||||
import { makeToast } from '@/modules/core/utils/Toast.ts';
|
import { makeToast } from '@/modules/core/utils/Toast.ts';
|
||||||
import { Mangas } from '@/modules/manga/services/Mangas.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 { Metadata as BaseMetadata } from '@/modules/core/components/texts/Metadata.tsx';
|
||||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||||
import { MangaType, SourceType } from '@/lib/graphql/generated/graphql.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 { 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 {
|
import {
|
||||||
MangaArtistInfo,
|
MangaArtistInfo,
|
||||||
MangaAuthorInfo,
|
MangaAuthorInfo,
|
||||||
@@ -58,12 +44,13 @@ import {
|
|||||||
MangaTitleInfo,
|
MangaTitleInfo,
|
||||||
MangaTrackRecordInfo,
|
MangaTrackRecordInfo,
|
||||||
} from '@/modules/manga/Manga.types.ts';
|
} from '@/modules/manga/Manga.types.ts';
|
||||||
import { TAppThemeContext, useAppThemeContext } from '@/modules/theme/contexts/AppThemeContext.tsx';
|
|
||||||
import { applyStyles } from '@/modules/core/utils/ApplyStyles.ts';
|
import { applyStyles } from '@/modules/core/utils/ApplyStyles.ts';
|
||||||
import { CustomButtonIcon } from '@/modules/core/components/buttons/CustomButtonIcon.tsx';
|
import { CustomButtonIcon } from '@/modules/core/components/buttons/CustomButtonIcon.tsx';
|
||||||
import { Sources } from '@/modules/source/services/Sources.ts';
|
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 { 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 }) => ({
|
const DetailsWrapper = styled('div')(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -177,37 +164,6 @@ function getSourceName(source?: Pick<SourceType, 'id' | 'displayName'> | null):
|
|||||||
return source.displayName ?? source.id;
|
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 (
|
|
||||||
<Link component={RouterLink} to={link} sx={{ textDecoration: 'none', color: 'inherit' }}>
|
|
||||||
{children ?? query}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const valuesToJoinedSearchLinks = (
|
const valuesToJoinedSearchLinks = (
|
||||||
values: string[] | undefined,
|
values: string[] | undefined,
|
||||||
sourceId: SourceIdInfo['id'] | undefined,
|
sourceId: SourceIdInfo['id'] | undefined,
|
||||||
@@ -221,209 +177,6 @@ const valuesToJoinedSearchLinks = (
|
|||||||
</>
|
</>
|
||||||
));
|
));
|
||||||
|
|
||||||
const Thumbnail = ({
|
|
||||||
manga,
|
|
||||||
mangaDynamicColorSchemes,
|
|
||||||
}: {
|
|
||||||
manga: Partial<MangaThumbnailInfo>;
|
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
<Stack
|
|
||||||
sx={{
|
|
||||||
position: 'relative',
|
|
||||||
borderRadius: 1,
|
|
||||||
overflow: 'hidden',
|
|
||||||
backgroundColor: 'background.paper',
|
|
||||||
width: '150px',
|
|
||||||
maxHeight: 'fit-content',
|
|
||||||
aspectRatio: MANGA_COVER_ASPECT_RATIO,
|
|
||||||
flexShrink: 0,
|
|
||||||
flexGrow: 0,
|
|
||||||
[theme.breakpoints.up('lg')]: {
|
|
||||||
width: '200px',
|
|
||||||
},
|
|
||||||
[theme.breakpoints.up('xl')]: {
|
|
||||||
width: '300px',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SpinnerImage
|
|
||||||
src={Mangas.getThumbnailUrl(manga)}
|
|
||||||
alt="Manga Thumbnail"
|
|
||||||
onLoad={() => setIsImageReady(true)}
|
|
||||||
imgStyle={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
||||||
/>
|
|
||||||
{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', p: 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 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<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}
|
|
||||||
sx={{
|
|
||||||
whiteSpace: 'pre-line',
|
|
||||||
textAlign: 'justify',
|
|
||||||
textJustify: 'inter-word',
|
|
||||||
mb: OPEN_CLOSE_BUTTON_HEIGHT,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{parseHtml(sanitizeHtml(description, { disallowedTagsMode: 'escape' }))}
|
|
||||||
</Typography>
|
|
||||||
</Collapse>
|
|
||||||
<Stack
|
|
||||||
onClick={() => 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})`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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) => (
|
|
||||||
<SearchLink key={genre} query={genre} sourceId={sourceId} mode={mode}>
|
|
||||||
<Chip label={genre} variant="outlined" onClick={() => {}} />
|
|
||||||
</SearchLink>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MangaDetails = ({
|
export const MangaDetails = ({
|
||||||
manga,
|
manga,
|
||||||
mode,
|
mode,
|
||||||
45
src/modules/manga/components/details/SearchLink.tsx
Normal file
45
src/modules/manga/components/details/SearchLink.tsx
Normal file
@@ -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 (
|
||||||
|
<Link component={RouterLink} to={link} sx={{ textDecoration: 'none', color: 'inherit' }}>
|
||||||
|
{children ?? query}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
146
src/modules/manga/components/details/Thumbnail.tsx
Normal file
146
src/modules/manga/components/details/Thumbnail.tsx
Normal file
@@ -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<MangaThumbnailInfo>;
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Stack
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'background.paper',
|
||||||
|
width: '150px',
|
||||||
|
maxHeight: 'fit-content',
|
||||||
|
aspectRatio: MANGA_COVER_ASPECT_RATIO,
|
||||||
|
flexShrink: 0,
|
||||||
|
flexGrow: 0,
|
||||||
|
[theme.breakpoints.up('lg')]: {
|
||||||
|
width: '200px',
|
||||||
|
},
|
||||||
|
[theme.breakpoints.up('xl')]: {
|
||||||
|
width: '300px',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SpinnerImage
|
||||||
|
src={Mangas.getThumbnailUrl(manga)}
|
||||||
|
alt="Manga Thumbnail"
|
||||||
|
onLoad={() => setIsImageReady(true)}
|
||||||
|
imgStyle={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
{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', p: 2, outline: 0, justifyContent: 'center', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<SpinnerImage
|
||||||
|
src={Mangas.getThumbnailUrl(manga)}
|
||||||
|
alt="Manga Thumbnail"
|
||||||
|
imgStyle={{ height: '100%', width: '100%', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -19,7 +19,7 @@ import { CustomTooltip } from '@/modules/core/components/CustomTooltip.tsx';
|
|||||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||||
import { ChapterList } from '@/modules/chapter/components/ChapterList.tsx';
|
import { ChapterList } from '@/modules/chapter/components/ChapterList.tsx';
|
||||||
import { useRefreshManga } from '@/modules/manga/hooks/useRefreshManga.ts';
|
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 { MangaToolbarMenu } from '@/modules/manga/components/MangaToolbarMenu.tsx';
|
||||||
import { EmptyViewAbsoluteCentered } from '@/modules/core/components/feedback/EmptyViewAbsoluteCentered.tsx';
|
import { EmptyViewAbsoluteCentered } from '@/modules/core/components/feedback/EmptyViewAbsoluteCentered.tsx';
|
||||||
import { LoadingPlaceholder } from '@/modules/core/components/feedback/LoadingPlaceholder.tsx';
|
import { LoadingPlaceholder } from '@/modules/core/components/feedback/LoadingPlaceholder.tsx';
|
||||||
|
|||||||
Reference in New Issue
Block a user