Files
suwayomi-material-you-webui/src/modules/manga/components/MangaDetails.tsx

521 lines
20 KiB
TypeScript
Raw Normal View History

/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
2021-01-26 23:32:12 +03:30
* 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/.
*/
2021-01-26 23:32:12 +03:30
2021-09-09 17:51:22 +04:30
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 { useTranslation } from 'react-i18next';
import { t as translate } from 'i18next';
import Link from '@mui/material/Link';
2024-05-23 00:40:20 +02:00
import Typography from '@mui/material/Typography';
2024-08-30 04:34:28 +02:00
import Chip from '@mui/material/Chip';
import LaunchIcon from '@mui/icons-material/Launch';
import Collapse from '@mui/material/Collapse';
2024-08-30 04:34:28 +02:00
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';
2024-10-08 02:56:22 +02:00
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { Vibrant } from 'node-vibrant/browser';
import { FastAverageColor } from 'fast-average-color';
2025-04-14 02:11:56 +02:00
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';
2024-10-05 23:22:42 +02:00
import { makeToast } from '@/modules/core/utils/Toast.ts';
import { Mangas } from '@/modules/manga/services/Mangas.ts';
2024-10-05 16:09:34 +02:00
import { SpinnerImage } from '@/modules/core/components/SpinnerImage.tsx';
import { CustomButton } from '@/modules/core/components/buttons/CustomButton.tsx';
2024-10-05 19:21:26 +02:00
import { TrackMangaButton } from '@/modules/manga/components/TrackMangaButton.tsx';
import { useManageMangaLibraryState } from '@/modules/manga/hooks/useManageMangaLibraryState.tsx';
2025-05-03 12:14:05 +02:00
import { Metadata as BaseMetadata } from '@/modules/core/components/texts/Metadata.tsx';
2024-10-05 16:09:34 +02:00
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
2024-07-08 18:02:50 +02:00
import { MangaType, SourceType } from '@/lib/graphql/generated/graphql.ts';
2024-10-05 16:09:34 +02:00
import { useLocalStorage } from '@/modules/core/hooks/useStorage.tsx';
import { useResizeObserver } from '@/modules/core/hooks/useResizeObserver.tsx';
2024-10-05 22:53:22 +02:00
import { useMetadataServerSettings } from '@/modules/settings/services/ServerSettingsMetadata.ts';
2025-05-05 01:46:11 +02:00
import { MANGA_COVER_ASPECT_RATIO, MANGA_STATUS_TO_TRANSLATION } from '@/modules/manga/Manga.constants.ts';
import {
MangaArtistInfo,
MangaAuthorInfo,
MangaDescriptionInfo,
MangaGenreInfo,
MangaIdInfo,
MangaInLibraryInfo,
MangaLocationState,
MangaSourceIdInfo,
MangaStatusInfo,
MangaThumbnailInfo,
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';
2025-04-21 17:45:14 +02:00
import { Sources } from '@/modules/source/services/Sources.ts';
import { AppRoutes } from '@/modules/core/AppRoute.constants.ts';
import { SourceIdInfo } from '@/modules/source/Source.types.ts';
2021-02-21 04:27:41 +03:30
const DetailsWrapper = styled('div')(({ theme }) => ({
2024-08-30 04:34:28 +02:00
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
padding: theme.spacing(1),
[theme.breakpoints.up('md')]: {
2024-08-30 04:34:28 +02:00
flexBasis: '40%',
height: 'calc(100vh - 64px)',
overflowY: 'auto',
},
}));
const TopContentWrapper = ({
url,
mangaThumbnailBackdrop,
children,
}: {
url: string;
mangaThumbnailBackdrop: boolean;
children: ReactNode;
}) => (
<Stack
sx={{
position: 'relative',
}}
>
{mangaThumbnailBackdrop && (
<>
<SpinnerImage
spinnerStyle={{ display: 'none' }}
imgStyle={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
src={url}
alt="Manga Thumbnail"
/>
<Stack
sx={{
'&::before': (theme) =>
applyStyles(mangaThumbnailBackdrop, {
position: 'absolute',
display: 'inline-block',
content: '""',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: `linear-gradient(to top, ${theme.palette.background.default}, transparent 100%, transparent 1px),linear-gradient(to right, ${theme.palette.background.default}, transparent 50%, transparent 1px),linear-gradient(to bottom, ${theme.palette.background.default}, transparent 50%, transparent 1px),linear-gradient(to left, ${theme.palette.background.default}, transparent 50%, transparent 1px)`,
backdropFilter: 'blur(4.5px) brightness(0.75)',
}),
}}
/>
</>
)}
{children}
</Stack>
);
const ThumbnailMetadataWrapper = styled('div')(({ theme }) => ({
display: 'flex',
paddingBottom: theme.spacing(1),
}));
2024-05-23 00:40:20 +02:00
const MetadataContainer = styled('div')(({ theme }) => ({
zIndex: 1,
2024-08-30 04:34:28 +02:00
marginLeft: theme.spacing(1),
}));
2024-05-23 00:40:20 +02:00
2024-08-30 04:34:28 +02:00
const Metadata = (props: ComponentProps<typeof BaseMetadata>) => <BaseMetadata {...props} />;
2024-05-23 00:40:20 +02:00
const MangaButtonsContainer = styled('div')(({ theme }) => ({
display: 'flex',
2024-08-30 04:34:28 +02:00
gap: theme.spacing(1),
}));
2021-01-19 20:20:28 +03:30
2023-09-08 00:44:01 +02:00
const OpenSourceButton = ({ url }: { url?: string | null }) => {
const { t } = useTranslation();
2024-08-30 04:34:28 +02:00
return (
<CustomTooltip title={t('global.button.open_site')} disabled={!url}>
<CustomButtonIcon
2024-08-30 04:34:28 +02:00
size="medium"
disabled={!url}
component={Link}
href={url ?? undefined}
target="_blank"
rel="noreferrer"
2024-08-30 04:34:28 +02:00
variant="outlined"
>
2024-08-30 04:34:28 +02:00
<LaunchIcon />
</CustomButtonIcon>
</CustomTooltip>
);
2023-09-08 00:44:01 +02:00
};
function getSourceName(source?: Pick<SourceType, 'id' | 'displayName'> | null): string {
if (!source) {
return translate('global.label.unknown');
}
2025-04-21 17:00:13 +02:00
if (Sources.isLocalSource(source)) {
return translate('source.local_source.title');
}
return source.displayName ?? source.id;
2021-01-19 20:20:28 +03:30
}
const SearchLink = ({
query,
sourceId,
mode,
children,
}: {
query: string;
sourceId: SourceIdInfo['id'] | undefined;
mode: MangaLocationState['mode'];
children?: ReactNode;
}) => {
const link = (() => {
const isSourceMode = mode === 'source' && sourceId !== undefined;
if (isSourceMode) {
return AppRoutes.sources.childRoutes.browse.path(sourceId, query);
}
return AppRoutes.library.path(undefined, query);
})();
return (
<Link component={RouterLink} to={link} sx={{ textDecoration: 'none', color: 'inherit' }}>
{children ?? query}
</Link>
);
};
const valuesToJoinedSearchLinks = (
values: string[] | undefined,
sourceId: SourceIdInfo['id'] | undefined,
mode: MangaLocationState['mode'],
) =>
values
?.map((value) => <SearchLink key={value} query={value} sourceId={sourceId} mode={mode} />)
.reduce((acc, valueLink) => (
<>
{acc}, {valueLink}
</>
));
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"
2024-11-06 13:00:36 +01:00
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;
2024-08-30 04:34:28 +02:00
const DescriptionGenre = ({
manga: { description, genre: mangaGenres, sourceId },
mode,
2024-08-30 04:34:28 +02:00
}: {
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;
2024-09-09 14:57:34 +02:00
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,
}}
>
2025-04-14 02:11:56 +02:00
{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}>
2025-05-17 19:12:04 +02:00
<Chip label={genre} variant="outlined" onClick={() => {}} />
</SearchLink>
))}
</Stack>
</>
);
};
2024-08-30 04:34:28 +02:00
2024-07-08 18:02:50 +02:00
export const MangaDetails = ({
manga,
mode,
2024-07-08 18:02:50 +02:00
}: {
manga: Pick<MangaType, 'realUrl'> &
MangaIdInfo &
MangaTitleInfo &
MangaStatusInfo &
MangaInLibraryInfo &
MangaAuthorInfo &
MangaArtistInfo &
MangaDescriptionInfo &
MangaGenreInfo &
2024-07-08 18:02:50 +02:00
MangaThumbnailInfo &
MangaSourceIdInfo &
2024-07-08 18:02:50 +02:00
MangaTrackRecordInfo & {
source?: Pick<SourceType, 'id' | 'displayName'> | null;
};
mode: MangaLocationState['mode'];
2024-07-08 18:02:50 +02:00
}) => {
const { t } = useTranslation();
const {
settings: { mangaThumbnailBackdrop, mangaDynamicColorSchemes },
} = useMetadataServerSettings();
useEffect(() => {
if (!manga.source) {
makeToast(translate('source.error.label.source_not_found'), 'error');
}
}, [manga.source]);
const { CategorySelectComponent, updateLibraryState } = useManageMangaLibraryState(manga);
2021-01-19 20:20:28 +03:30
2024-10-08 02:56:22 +02:00
const copyTitle = async () => {
2024-05-23 02:28:37 +02:00
try {
await navigator.clipboard.writeText(manga.title);
2024-09-28 03:24:59 +02:00
makeToast(t('global.label.copied_clipboard'), 'info');
2024-05-23 02:28:37 +02:00
} catch (e) {
defaultPromiseErrorHandler('MangaDetails::copyTitleLongPress')(e);
}
2024-10-08 02:56:22 +02:00
};
2024-05-23 02:28:37 +02:00
2021-01-19 20:20:28 +03:30
return (
<>
<DetailsWrapper>
<TopContentWrapper url={Mangas.getThumbnailUrl(manga)} mangaThumbnailBackdrop={mangaThumbnailBackdrop}>
<ThumbnailMetadataWrapper>
<Thumbnail manga={manga} mangaDynamicColorSchemes={mangaDynamicColorSchemes} />
2024-05-23 00:40:20 +02:00
<MetadataContainer>
2024-10-08 02:56:22 +02:00
<Stack sx={{ flexDirection: 'row', gap: 1, alignItems: 'flex-start', mb: 1 }}>
<Typography variant="h5" component="h2" sx={{ wordBreak: 'break-word' }}>
{manga.title}
</Typography>
<CustomTooltip title={t('global.button.copy')}>
2024-10-08 02:56:22 +02:00
<IconButton onClick={copyTitle} color="inherit">
<ContentCopyIcon fontSize="small" />
</IconButton>
</CustomTooltip>
2024-10-08 02:56:22 +02:00
</Stack>
2025-05-30 17:58:00 +02:00
{manga.author && (
<Metadata
title={t('manga.label.author')}
value={valuesToJoinedSearchLinks(Mangas.getAuthors(manga), manga.source?.id, mode)}
2025-05-30 17:58:00 +02:00
/>
)}
{manga.artist && (
<Metadata
title={t('manga.label.artist')}
value={valuesToJoinedSearchLinks(Mangas.getArtists(manga), manga.source?.id, mode)}
2025-05-30 17:58:00 +02:00
/>
)}
2025-05-05 01:46:11 +02:00
<Metadata
title={t('manga.label.status')}
value={t(MANGA_STATUS_TO_TRANSLATION[manga.status])}
/>
2024-07-01 19:51:27 +02:00
<Metadata title={t('source.title_one')} value={getSourceName(manga.source)} />
2024-05-23 00:40:20 +02:00
</MetadataContainer>
</ThumbnailMetadataWrapper>
<MangaButtonsContainer>
<CustomButton
2024-08-30 04:34:28 +02:00
size="medium"
onClick={updateLibraryState}
2024-08-30 04:34:28 +02:00
variant={manga.inLibrary ? 'contained' : 'outlined'}
>
{manga.inLibrary ? <FavoriteIcon /> : <FavoriteBorderIcon />}
{manga.inLibrary ? t('manga.button.in_library') : t('manga.button.add_to_library')}
</CustomButton>
2024-03-10 17:36:22 +01:00
<TrackMangaButton manga={manga} />
<OpenSourceButton url={manga.realUrl} />
</MangaButtonsContainer>
</TopContentWrapper>
<DescriptionGenre manga={manga} mode={mode} />
</DetailsWrapper>
{CategorySelectComponent}
</>
2021-01-19 20:20:28 +03:30
);
};