Files
suwayomi-material-you-webui/src/components/MangaCard.tsx
T

501 lines
26 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 Card from '@mui/material/Card';
import CardActionArea from '@mui/material/CardActionArea';
import Typography from '@mui/material/Typography';
2024-04-07 15:21:25 +02:00
import { Link as RouterLink } from 'react-router-dom';
import { Avatar, Box, Button, CardContent, Link, Stack, styled, Tooltip } from '@mui/material';
2023-03-23 13:59:32 +01:00
import { useTranslation } from 'react-i18next';
2023-12-25 21:37:45 +01:00
import PopupState, { bindMenu } from 'material-ui-popup-state';
2024-04-07 15:21:25 +02:00
import { useRef, useState } from 'react';
2024-04-05 02:03:00 +02:00
import { useLongPress } from 'use-long-press';
import { isMobile } from 'react-device-detect';
2023-06-05 01:42:39 +02:00
import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
2023-10-28 00:32:02 +02:00
import { SpinnerImage } from '@/components/util/SpinnerImage';
2023-12-25 21:37:45 +01:00
import { TManga, TPartialManga } from '@/typings.ts';
2023-12-19 15:17:00 +01:00
import { ContinueReadingButton } from '@/components/manga/ContinueReadingButton.tsx';
2023-12-25 21:37:45 +01:00
import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts';
import { MangaOptionButton } from '@/components/manga/MangaOptionButton.tsx';
2023-12-30 14:26:50 +01:00
import { MangaActionMenuItems, SingleModeProps } from '@/components/manga/MangaActionMenuItems.tsx';
2023-12-30 14:30:04 +01:00
import { Menu } from '@/components/menu/Menu.tsx';
2024-01-26 20:50:51 +01:00
import { MigrateDialog } from '@/components/MigrateDialog.tsx';
import { Mangas } from '@/lib/data/Mangas.ts';
2024-03-29 16:54:53 +01:00
import { TypographyMaxLines } from '@/components/atoms/TypographyMaxLines.tsx';
import { useManageMangaLibraryState } from '@/components/manga/useManageMangaLibraryState.tsx';
2020-12-25 17:42:22 +01:00
2021-10-31 21:02:45 +03:30
const BottomGradient = styled('div')({
position: 'absolute',
bottom: 0,
width: '100%',
height: '30%',
background: 'linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 100%)',
});
2021-05-28 19:37:26 +04:30
2021-10-31 21:02:45 +03:30
const BottomGradientDoubledDown = styled('div')({
position: 'absolute',
bottom: 0,
width: '100%',
height: '20%',
background: 'linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 100%)',
});
2024-03-29 16:54:53 +01:00
const MangaTitle = TypographyMaxLines;
2023-06-17 16:50:17 +02:00
const GridMangaTitle = styled(MangaTitle)({
2021-10-31 21:02:45 +03:30
fontSize: '1.05rem',
});
const BadgeContainer = styled('div')({
display: 'flex',
height: 'fit-content',
2021-10-31 21:02:45 +03:30
borderRadius: '5px',
overflow: 'hidden',
'& p': {
color: 'white',
padding: '0.1em',
paddingInline: '0.2em',
fontSize: '1.05rem',
},
});
2020-12-25 17:42:22 +01:00
type MangaCardMode = 'default' | 'source' | 'migrate.search' | 'migrate.select';
2024-01-26 20:50:51 +01:00
export interface MangaCardProps {
2023-10-15 16:03:08 +02:00
manga: TPartialManga;
2023-02-06 10:06:33 +01:00
gridLayout?: GridLayout;
inLibraryIndicator?: boolean;
2023-12-25 21:37:45 +01:00
selected?: boolean | null;
handleSelection?: SelectableCollectionReturnType<TManga['id']>['handleSelection'];
2024-01-26 20:50:51 +01:00
mode?: MangaCardMode;
2020-12-25 17:42:22 +01:00
}
2023-02-06 10:06:33 +01:00
2024-03-28 17:30:34 +01:00
const getMangaLinkTo = (
mode: MangaCardMode,
mangaId: number,
sourceId: string | undefined,
mangaTitle: string,
): string => {
2024-01-26 20:50:51 +01:00
switch (mode) {
case 'default':
case 'source':
2024-01-26 20:50:51 +01:00
return `/manga/${mangaId}/`;
case 'migrate.search':
return `/migrate/source/${sourceId}/manga/${mangaId}/search?query=${mangaTitle}`;
case 'migrate.select':
return '';
default:
throw new Error(`getMangaLinkTo: unexpected MangaCardMode "${mode}"`);
}
};
export const MangaCard = (props: MangaCardProps) => {
2023-03-23 13:59:32 +01:00
const { t } = useTranslation();
2024-04-07 15:21:25 +02:00
const optionButtonRef = useRef<HTMLButtonElement>(null);
2024-01-26 20:50:51 +01:00
const { manga, gridLayout, inLibraryIndicator, selected, handleSelection, mode = 'default' } = props;
const { id, title, downloadCount, unreadCount: unread, latestReadChapter, firstUnreadChapter, chapters } = manga;
const thumbnailUrl = Mangas.getThumbnailUrl(manga);
2023-02-06 10:06:33 +01:00
const {
2023-12-19 15:17:00 +01:00
options: { showContinueReadingButton, showUnreadBadge, showDownloadBadge },
2023-02-06 10:06:33 +01:00
} = useLibraryOptionsContext();
2021-10-31 21:02:45 +03:30
const { CategorySelectComponent, updateLibraryState, isInLibrary } = useManageMangaLibraryState(manga);
2024-01-26 20:50:51 +01:00
const mangaLinkTo = getMangaLinkTo(mode, manga.id, manga.source?.id, manga.title);
2022-11-21 01:33:45 +01:00
const nextChapterIndexToRead = firstUnreadChapter?.sourceOrder ?? 1;
const isLatestChapterRead = chapters?.totalCount === latestReadChapter?.sourceOrder;
2023-12-19 15:17:00 +01:00
2024-01-26 20:50:51 +01:00
const [isMigrateDialogOpen, setIsMigrateDialogOpen] = useState(false);
2024-04-07 15:21:25 +02:00
const handleClick = (event: React.MouseEvent | React.TouchEvent, openMenu?: () => void) => {
const isDefaultMode = mode === 'default';
const isSourceMode = mode === 'source';
2024-04-07 15:21:25 +02:00
const isMigrateSelectMode = mode === 'migrate.select';
const isSelectionMode = selected !== null;
const isLongPress = !!openMenu;
2024-04-07 15:21:25 +02:00
const shouldHandleClick =
isMigrateSelectMode || isSelectionMode || ((isDefaultMode || isSourceMode) && isLongPress);
2024-04-07 15:21:25 +02:00
if (!shouldHandleClick) {
2024-04-05 02:03:00 +02:00
return;
}
2024-04-07 15:21:25 +02:00
event.preventDefault();
if (isSourceMode) {
updateLibraryState();
return;
}
2024-04-07 15:21:25 +02:00
if (isSelectionMode) {
handleSelection?.(id, !selected, { selectRange: event.shiftKey });
return;
}
if (isDefaultMode) {
openMenu?.();
return;
}
if (isMigrateSelectMode) {
setIsMigrateDialogOpen(true);
}
2024-04-05 02:03:00 +02:00
};
2024-04-07 15:21:25 +02:00
const longPressBind = useLongPress((e, { context }) => {
2024-04-05 02:03:00 +02:00
e.shiftKey = true;
2024-04-07 15:21:25 +02:00
handleClick(e, context as () => {});
2024-04-05 02:03:00 +02:00
});
if (gridLayout !== GridLayout.List) {
2022-03-06 21:03:15 +00:00
return (
2024-01-26 20:50:51 +01:00
<>
{isMigrateDialogOpen && (
<MigrateDialog mangaIdToMigrateTo={manga.id} onClose={() => setIsMigrateDialogOpen(false)} />
)}
<PopupState variant="popover" popupId="manga-card-action-menu">
{(popupState) => (
<>
<Link
2024-04-07 15:21:25 +02:00
component={RouterLink}
{...longPressBind(() => popupState.open(optionButtonRef.current))}
onClick={handleClick}
2024-01-26 20:50:51 +01:00
to={mangaLinkTo}
2024-04-07 15:21:25 +02:00
sx={{ textDecoration: 'none', touchCallout: 'none' }}
2024-01-26 20:50:51 +01:00
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
margin: '2px',
outline: selected ? '4px solid' : undefined,
borderRadius: selected ? '1px' : undefined,
outlineColor: (theme) => theme.palette.primary.main,
backgroundColor: (theme) => (selected ? theme.palette.primary.main : undefined),
'@media (hover: hover) and (pointer: fine)': {
'&:hover .manga-option-button': {
visibility: 'visible',
pointerEvents: 'all',
},
},
'&:hover .source-manga-library-state-button': {
display: isMobile ? 'none' : 'inline-flex',
},
'&:hover .source-manga-library-state-indicator': {
display: 'none',
},
2024-01-26 20:50:51 +01:00
}}
>
<Card
sx={{
// force standard aspect ratio of manga covers
aspectRatio: '225/350',
display: 'flex',
}}
>
<CardActionArea
sx={{
position: 'relative',
height: '100%',
}}
>
<SpinnerImage
alt={title}
src={thumbnailUrl}
imgStyle={
inLibraryIndicator && isInLibrary
? {
height: '100%',
width: '100%',
objectFit: 'cover',
filter: 'brightness(0.4)',
}
: {
height: '100%',
width: '100%',
objectFit: 'cover',
}
}
spinnerStyle={{
display: 'grid',
placeItems: 'center',
}}
/>
2024-01-26 20:50:51 +01:00
<Stack
alignItems="start"
justifyContent="space-between"
direction="row"
sx={{
position: 'absolute',
top: 5,
left: 5,
right: 5,
}}
>
<BadgeContainer>
{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' }}
>
2024-01-26 20:50:51 +01:00
{t('manga.button.in_library')}
</Typography>
)}
{showUnreadBadge && (unread ?? 0) > 0 && (
<Typography sx={{ backgroundColor: 'primary.dark' }}>
{unread}
</Typography>
)}
{showDownloadBadge && (downloadCount ?? 0) > 0 && (
<Typography
sx={{
backgroundColor: 'success.dark',
}}
>
{downloadCount}
</Typography>
)}
</BadgeContainer>
<MangaOptionButton
2024-04-07 15:21:25 +02:00
ref={optionButtonRef}
2024-01-26 20:50:51 +01:00
popupState={popupState}
id={id}
selected={selected}
handleSelection={handleSelection}
/>
</Stack>
<>
{gridLayout !== GridLayout.Comfortable && (
<>
<BottomGradient />
<BottomGradientDoubledDown />
</>
)}
<Stack
direction="row"
justifyContent={
gridLayout !== GridLayout.Comfortable ? 'space-between' : 'end'
}
alignItems="end"
sx={{
position: 'absolute',
bottom: 0,
width: '100%',
margin: '0.5em 0',
padding: '0 0.5em',
gap: '0.5em',
}}
>
{gridLayout !== GridLayout.Comfortable && (
<Tooltip title={title} placement="top">
<GridMangaTitle
sx={{
color: 'white',
textShadow: '0px 0px 3px #000000',
}}
>
{title}
</GridMangaTitle>
</Tooltip>
)}
<ContinueReadingButton
showContinueReadingButton={showContinueReadingButton}
isLatestChapterRead={isLatestChapterRead}
nextChapterIndexToRead={nextChapterIndexToRead}
mangaLinkTo={mangaLinkTo}
/>
</Stack>
</>
</CardActionArea>
</Card>
{gridLayout === GridLayout.Comfortable && (
<Tooltip title={title} placement="top">
<GridMangaTitle
sx={{
position: 'relative',
width: '100%',
bottom: 0,
margin: '0.5em 0',
padding: '0 0.5em',
color: 'text.primary',
height: '3rem',
}}
>
{title}
</GridMangaTitle>
</Tooltip>
)}
</Box>
</Link>
{!!handleSelection && popupState.isOpen && (
<Menu {...bindMenu(popupState)}>
{(onClose, setHideMenu) => (
<MangaActionMenuItems
manga={manga as SingleModeProps['manga']}
handleSelection={handleSelection}
onClose={onClose}
setHideMenu={setHideMenu}
/>
)}
</Menu>
)}
{CategorySelectComponent}
2024-01-26 20:50:51 +01:00
</>
)}
</PopupState>
</>
);
}
return (
<>
{isMigrateDialogOpen && (
<MigrateDialog mangaIdToMigrateTo={manga.id} onClose={() => setIsMigrateDialogOpen(false)} />
)}
2023-12-25 21:37:45 +01:00
<PopupState variant="popover" popupId="manga-card-action-menu">
{(popupState) => (
<>
2024-01-26 20:50:51 +01:00
<Card>
<CardActionArea
2024-04-07 15:21:25 +02:00
component={RouterLink}
2024-01-26 20:50:51 +01:00
to={mangaLinkTo}
2024-04-05 02:03:00 +02:00
onClick={handleClick}
{...longPressBind()}
2024-04-07 15:21:25 +02:00
sx={{ touchCallout: 'none' }}
2022-03-06 21:03:15 +00:00
>
2024-01-26 20:50:51 +01:00
<CardContent
2023-12-19 15:17:00 +01:00
sx={{
2023-12-25 21:37:45 +01:00
display: 'flex',
2024-01-26 20:50:51 +01:00
justifyContent: 'space-between',
alignItems: 'center',
padding: 2,
position: 'relative',
2023-12-19 15:17:00 +01:00
}}
>
2024-01-26 20:50:51 +01:00
<Avatar
variant="rounded"
sx={{
width: 56,
height: 56,
flex: '0 0 auto',
marginRight: 2,
}}
>
<SpinnerImage
spinnerStyle={{ small: true }}
imgStyle={{
objectFit: 'cover',
width: '100%',
height: '100%',
imageRendering: 'pixelated',
filter:
inLibraryIndicator && isInLibrary ? 'brightness(0.4)' : undefined,
}}
alt={manga.title}
src={thumbnailUrl}
/>
</Avatar>
2024-01-26 20:50:51 +01:00
<Box
2023-12-25 21:37:45 +01:00
sx={{
2024-01-26 20:50:51 +01:00
display: 'flex',
flexDirection: 'row',
flexGrow: 1,
width: 'min-content',
2023-12-25 21:37:45 +01:00
}}
>
2024-01-26 20:50:51 +01:00
<Tooltip title={title} placement="top">
<MangaTitle variant="h5">{title}</MangaTitle>
</Tooltip>
</Box>
<Stack direction="row" alignItems="center" gap="5px">
<BadgeContainer>
{inLibraryIndicator && isInLibrary && (
2024-01-26 20:50:51 +01:00
<Typography sx={{ backgroundColor: 'primary.dark' }}>
{t('manga.button.in_library')}
</Typography>
2023-12-25 21:37:45 +01:00
)}
2024-01-26 20:50:51 +01:00
{showUnreadBadge && unread! > 0 && (
<Typography sx={{ backgroundColor: 'primary.dark' }}>
{unread}
</Typography>
)}
{showDownloadBadge && downloadCount! > 0 && (
<Typography
sx={{
backgroundColor: 'success.dark',
}}
>
{downloadCount}
</Typography>
)}
</BadgeContainer>
<ContinueReadingButton
showContinueReadingButton={showContinueReadingButton}
isLatestChapterRead={isLatestChapterRead}
nextChapterIndexToRead={nextChapterIndexToRead}
mangaLinkTo={mangaLinkTo}
/>
<MangaOptionButton
2024-04-07 15:21:25 +02:00
ref={optionButtonRef}
2024-01-26 20:50:51 +01:00
popupState={popupState}
id={id}
selected={selected}
handleSelection={handleSelection}
asCheckbox
/>
</Stack>
</CardContent>
</CardActionArea>
</Card>
2023-12-25 21:37:45 +01:00
{!!handleSelection && popupState.isOpen && (
2023-12-30 14:26:50 +01:00
<Menu {...bindMenu(popupState)}>
{(onClose, setHideMenu) => (
<MangaActionMenuItems
manga={manga as SingleModeProps['manga']}
handleSelection={handleSelection}
onClose={onClose}
setHideMenu={setHideMenu}
/>
)}
</Menu>
2023-12-25 21:37:45 +01:00
)}
{CategorySelectComponent}
2023-12-25 21:37:45 +01:00
</>
)}
</PopupState>
2024-01-26 20:50:51 +01:00
</>
2020-12-25 17:42:22 +01:00
);
2023-06-17 16:50:17 +02:00
};