Feature/manga migration (#536)

* Add "migration" tab to "Browse" screen

* Add missing useEffect cleanup for navbar title and actions

* Make "SourceGridLayout" reusable

* Add migration tab to "Browse"

* Add migration logic
This commit is contained in:
schroda
2024-01-26 20:50:51 +01:00
committed by GitHub
parent 5224ad1391
commit e0c5e0521d
39 changed files with 1329 additions and 365 deletions

View File

@@ -13,6 +13,7 @@ import { Link } from 'react-router-dom';
import { Avatar, Box, CardContent, Stack, styled, Tooltip } from '@mui/material';
import { useTranslation } from 'react-i18next';
import PopupState, { bindMenu } from 'material-ui-popup-state';
import { useState } from 'react';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import { SpinnerImage } from '@/components/util/SpinnerImage';
@@ -22,6 +23,7 @@ import { SelectableCollectionReturnType } from '@/components/collection/useSelec
import { MangaOptionButton } from '@/components/manga/MangaOptionButton.tsx';
import { MangaActionMenuItems, SingleModeProps } from '@/components/manga/MangaActionMenuItems.tsx';
import { Menu } from '@/components/menu/Menu.tsx';
import { MigrateDialog } from '@/components/MigrateDialog.tsx';
const BottomGradient = styled('div')({
position: 'absolute',
@@ -66,18 +68,34 @@ const BadgeContainer = styled('div')({
},
});
interface IProps {
type MangaCardMode = 'default' | 'migrate.search' | 'migrate.select';
export interface MangaCardProps {
manga: TPartialManga;
gridLayout?: GridLayout;
inLibraryIndicator?: boolean;
selected?: boolean | null;
handleSelection?: SelectableCollectionReturnType<TManga['id']>['handleSelection'];
mode?: MangaCardMode;
}
export const MangaCard = (props: IProps) => {
const getMangaLinkTo = (mode: MangaCardMode, mangaId: number, sourceId: string, mangaTitle: string): string => {
switch (mode) {
case 'default':
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) => {
const { t } = useTranslation();
const { manga, gridLayout, inLibraryIndicator, selected, handleSelection } = props;
const { manga, gridLayout, inLibraryIndicator, selected, handleSelection, mode = 'default' } = props;
const {
id,
title,
@@ -93,182 +111,322 @@ export const MangaCard = (props: IProps) => {
options: { showContinueReadingButton, showUnreadBadge, showDownloadBadge },
} = useLibraryOptionsContext();
const mangaLinkTo = `/manga/${id}/`;
const mangaLinkTo = getMangaLinkTo(mode, manga.id, manga.source?.id, manga.title);
const nextChapterIndexToRead = (latestReadChapter?.sourceOrder ?? 0) + 1;
const isLatestChapterRead = chapters?.totalCount === latestReadChapter?.sourceOrder;
const [isMigrateDialogOpen, setIsMigrateDialogOpen] = useState(false);
if (gridLayout !== GridLayout.List) {
return (
<>
{isMigrateDialogOpen && (
<MigrateDialog mangaIdToMigrateTo={manga.id} onClose={() => setIsMigrateDialogOpen(false)} />
)}
<PopupState variant="popover" popupId="manga-card-action-menu">
{(popupState) => (
<>
<Link
onClick={(e) => {
const isMigrateSelectMode = mode === 'migrate.select';
const isSelectionMode = selected !== null;
const handleClick = isMigrateSelectMode || isSelectionMode;
if (!handleClick) {
return;
}
e.preventDefault();
if (isMigrateSelectMode) {
setIsMigrateDialogOpen(true);
return;
}
handleSelection?.(id, !selected);
}}
to={mangaLinkTo}
style={{ textDecoration: 'none' }}
>
<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',
},
},
}}
>
<Card
sx={{
// force standard aspect ratio of manga covers
aspectRatio: '225/350',
display: 'flex',
}}
>
<CardActionArea
sx={{
position: 'relative',
height: '100%',
}}
>
<Stack
alignItems="start"
justifyContent="space-between"
direction="row"
sx={{
position: 'absolute',
top: 5,
left: 5,
right: 5,
}}
>
<BadgeContainer>
{inLibraryIndicator && inLibrary && (
<Typography
sx={{ backgroundColor: 'primary.dark', zIndex: '1' }}
>
{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
popupState={popupState}
id={id}
selected={selected}
handleSelection={handleSelection}
/>
</Stack>
<SpinnerImage
alt={title}
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
imgStyle={
inLibraryIndicator && inLibrary
? {
height: '100%',
width: '100%',
objectFit: 'cover',
filter: 'brightness(0.4)',
}
: {
height: '100%',
width: '100%',
objectFit: 'cover',
}
}
spinnerStyle={{
display: 'grid',
placeItems: 'center',
}}
/>
<>
{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>
)}
</>
)}
</PopupState>
</>
);
}
return (
<>
{isMigrateDialogOpen && (
<MigrateDialog mangaIdToMigrateTo={manga.id} onClose={() => setIsMigrateDialogOpen(false)} />
)}
<PopupState variant="popover" popupId="manga-card-action-menu">
{(popupState) => (
<>
<Link
onClick={(e) => {
if (selected === null) {
return;
}
<Card>
<CardActionArea
component={Link}
to={mangaLinkTo}
onClick={(e) => {
if (selected === null) {
return;
}
e.preventDefault();
handleSelection?.(id, !selected);
}}
to={mangaLinkTo}
style={{ textDecoration: 'none' }}
>
<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',
},
},
e.preventDefault();
handleSelection?.(id, !selected);
}}
>
<Card
<CardContent
sx={{
// force standard aspect ratio of manga covers
aspectRatio: '225/350',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: 2,
position: 'relative',
}}
>
<CardActionArea
<Avatar
variant="rounded"
sx={
inLibraryIndicator && inLibrary
? {
width: 56,
height: 56,
flex: '0 0 auto',
marginRight: 2,
imageRendering: 'pixelated',
filter: 'brightness(0.4)',
}
: {
width: 56,
height: 56,
flex: '0 0 auto',
marginRight: 2,
imageRendering: 'pixelated',
}
}
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
/>
<Box
sx={{
position: 'relative',
height: '100%',
display: 'flex',
flexDirection: 'row',
flexGrow: 1,
width: 'min-content',
}}
>
<Stack
alignItems="start"
justifyContent="space-between"
direction="row"
sx={{
position: 'absolute',
top: 5,
left: 5,
right: 5,
}}
>
<BadgeContainer>
{inLibraryIndicator && inLibrary && (
<Typography sx={{ backgroundColor: 'primary.dark', zIndex: '1' }}>
{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
popupState={popupState}
id={id}
selected={selected}
handleSelection={handleSelection}
/>
</Stack>
<SpinnerImage
alt={title}
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
imgStyle={
inLibraryIndicator && inLibrary
? {
height: '100%',
width: '100%',
objectFit: 'cover',
filter: 'brightness(0.4)',
}
: {
height: '100%',
width: '100%',
objectFit: 'cover',
}
}
spinnerStyle={{
display: 'grid',
placeItems: 'center',
}}
/>
<>
{gridLayout !== GridLayout.Comfortable && (
<>
<BottomGradient />
<BottomGradientDoubledDown />
</>
<Tooltip title={title} placement="top">
<MangaTitle variant="h5">{title}</MangaTitle>
</Tooltip>
</Box>
<Stack direction="row" alignItems="center" gap="5px">
<BadgeContainer>
{inLibraryIndicator && inLibrary && (
<Typography sx={{ backgroundColor: 'primary.dark' }}>
{t('manga.button.in_library')}
</Typography>
)}
<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>
{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
popupState={popupState}
id={id}
selected={selected}
handleSelection={handleSelection}
asCheckbox
/>
</Stack>
</CardContent>
</CardActionArea>
</Card>
{!!handleSelection && popupState.isOpen && (
<Menu {...bindMenu(popupState)}>
{(onClose, setHideMenu) => (
@@ -284,120 +442,6 @@ export const MangaCard = (props: IProps) => {
</>
)}
</PopupState>
);
}
return (
<PopupState variant="popover" popupId="manga-card-action-menu">
{(popupState) => (
<>
<Card>
<CardActionArea
component={Link}
to={mangaLinkTo}
onClick={(e) => {
if (selected === null) {
return;
}
e.preventDefault();
handleSelection?.(id, !selected);
}}
>
<CardContent
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: 2,
position: 'relative',
}}
>
<Avatar
variant="rounded"
sx={
inLibraryIndicator && inLibrary
? {
width: 56,
height: 56,
flex: '0 0 auto',
marginRight: 2,
imageRendering: 'pixelated',
filter: 'brightness(0.4)',
}
: {
width: 56,
height: 56,
flex: '0 0 auto',
marginRight: 2,
imageRendering: 'pixelated',
}
}
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
/>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
flexGrow: 1,
width: 'min-content',
}}
>
<Tooltip title={title} placement="top">
<MangaTitle variant="h5">{title}</MangaTitle>
</Tooltip>
</Box>
<Stack direction="row" alignItems="center" gap="5px">
<BadgeContainer>
{inLibraryIndicator && inLibrary && (
<Typography sx={{ backgroundColor: 'primary.dark' }}>
{t('manga.button.in_library')}
</Typography>
)}
{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
popupState={popupState}
id={id}
selected={selected}
handleSelection={handleSelection}
asCheckbox
/>
</Stack>
</CardContent>
</CardActionArea>
</Card>
{!!handleSelection && popupState.isOpen && (
<Menu {...bindMenu(popupState)}>
{(onClose, setHideMenu) => (
<MangaActionMenuItems
manga={manga as SingleModeProps['manga']}
handleSelection={handleSelection}
onClose={onClose}
setHideMenu={setHideMenu}
/>
)}
</Menu>
)}
</>
)}
</PopupState>
</>
);
};

View File

@@ -13,7 +13,7 @@ import { GridItemProps, GridStateSnapshot, VirtuosoGrid } from 'react-virtuoso';
import { useLocation, useNavigate } from 'react-router-dom';
import { EmptyView } from '@/components/util/EmptyView';
import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder';
import { MangaCard } from '@/components/MangaCard';
import { MangaCard, MangaCardProps } from '@/components/MangaCard';
import { GridLayout } from '@/components/context/LibraryOptionsContext';
import { useLocalStorage } from '@/util/useLocalStorage';
import { TManga, TPartialManga } from '@/typings.ts';
@@ -49,6 +49,7 @@ const createMangaCard = (
isSelectModeActive: boolean = false,
selectedMangaIds?: TManga['id'][],
handleSelection?: DefaultGridProps['handleSelection'],
mode?: MangaCardProps['mode'],
) => (
<MangaCard
key={manga.id}
@@ -57,10 +58,11 @@ const createMangaCard = (
inLibraryIndicator={inLibraryIndicator}
selected={isSelectModeActive ? selectedMangaIds?.includes(manga.id) : null}
handleSelection={handleSelection}
mode={mode}
/>
);
type DefaultGridProps = {
type DefaultGridProps = Pick<MangaCardProps, 'mode'> & {
isLoading: boolean;
mangas: TPartialManga[];
inLibraryIndicator?: boolean;
@@ -80,6 +82,7 @@ const HorizontalGrid = ({
isSelectModeActive,
selectedMangaIds,
handleSelection,
mode,
}: DefaultGridProps) => (
<Grid
container
@@ -105,6 +108,7 @@ const HorizontalGrid = ({
isSelectModeActive,
selectedMangaIds,
handleSelection,
mode,
)}
</GridItemContainer>
))
@@ -123,6 +127,7 @@ const VerticalGrid = ({
isSelectModeActive,
selectedMangaIds,
handleSelection,
mode,
}: DefaultGridProps & {
hasNextPage: boolean;
loadMore: () => void;
@@ -171,6 +176,7 @@ const VerticalGrid = ({
isSelectModeActive,
selectedMangaIds,
handleSelection,
mode,
)
}
/>
@@ -212,6 +218,7 @@ export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
isSelectModeActive,
selectedMangaIds,
handleSelection,
mode,
} = props;
const [dimensions, setDimensions] = useState(document.documentElement.offsetWidth);
@@ -287,6 +294,7 @@ export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
isSelectModeActive={isSelectModeActive}
selectedMangaIds={selectedMangaIds}
handleSelection={handleSelection}
mode={mode}
/>
) : (
<VerticalGrid
@@ -300,6 +308,7 @@ export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
isSelectModeActive={isSelectModeActive}
selectedMangaIds={selectedMangaIds}
handleSelection={handleSelection}
mode={mode}
/>
)}
</div>

View File

@@ -0,0 +1,97 @@
/*
* 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 Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import { useTranslation } from 'react-i18next';
import { Stack } from '@mui/material';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useState } from 'react';
import FormGroup from '@mui/material/FormGroup';
import { CheckboxInput } from '@/components/atoms/CheckboxInput.tsx';
import { Mangas, MigrateMode } from '@/lib/data/Mangas.ts';
import { makeToast } from '@/components/util/Toast.tsx';
export const MigrateDialog = ({ mangaIdToMigrateTo, onClose }: { mangaIdToMigrateTo: number; onClose: () => void }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { mangaId: mangaIdAsString } = useParams<{ mangaId: string }>();
const mangaId = Number(mangaIdAsString);
const [includeChapters, setIncludeChapters] = useState(true);
const [includeCategories, setIncludeCategories] = useState(true);
const [isMigrationInProcess, setIsMigrationInProcess] = useState(false);
const migrate = async (mode: MigrateMode) => {
if (mangaId == null) {
throw new Error(`MigrateDialog::migrate: unexpected mangaId "${mangaId}"`);
}
makeToast(t('migrate.label.info'), 'info');
setIsMigrationInProcess(true);
try {
await Mangas.migrate(mangaId, mangaIdToMigrateTo, {
mode,
migrateChapters: includeChapters,
migrateCategories: includeCategories,
});
navigate(`/manga/${mangaIdToMigrateTo}`);
} catch (e) {
setIsMigrationInProcess(false);
}
};
return (
<Dialog open fullWidth onClose={onClose}>
<DialogTitle>{t('migrate.dialog.title')}</DialogTitle>
<DialogContent dividers>
<FormGroup>
<CheckboxInput
disabled={isMigrationInProcess}
label={t('chapter.title')}
checked={includeChapters}
onChange={(_, checked) => setIncludeChapters(checked)}
/>
<CheckboxInput
disabled={isMigrationInProcess}
label={t('category.title.category_one')}
checked={includeCategories}
onChange={(_, checked) => setIncludeCategories(checked)}
/>
</FormGroup>
</DialogContent>
<DialogActions>
<Stack sx={{ width: '100%' }} direction="row" justifyContent="space-between">
<Button disabled={isMigrationInProcess} component={Link} to={`/manga/${mangaIdToMigrateTo}`}>
{t('migrate.dialog.action.button.show_entry')}
</Button>
<Stack direction="row">
<Button disabled={isMigrationInProcess} onClick={onClose}>
{t('global.button.cancel')}
</Button>
<Button disabled={isMigrationInProcess} onClick={() => migrate('copy')}>
{t('global.button.copy')}
</Button>
<Button disabled={isMigrationInProcess} onClick={() => migrate('migrate')}>
{t('global.button.migrate')}
</Button>
</Stack>
</Stack>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,60 @@
/*
* 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 Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import { Box, CardActionArea, Chip } from '@mui/material';
import Avatar from '@mui/material/Avatar';
import Typography from '@mui/material/Typography';
import { Link } from 'react-router-dom';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { GetMigratableSourcesQuery } from '@/lib/graphql/generated/graphql.ts';
import { translateExtensionLanguage } from '@/screens/util/Extensions.ts';
export type TMigratableSource = NonNullable<GetMigratableSourcesQuery['mangas']['nodes'][number]['source']> & {
mangaCount: number;
};
// TODO - cleanup source/extension components
export const MigrationCard = ({ id, name, lang, iconUrl, mangaCount }: TMigratableSource) => (
<Card>
<CardActionArea component={Link} to={`/migrate/source/${id}/`}>
<CardContent
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: 2,
}}
>
<Box sx={{ display: 'flex' }}>
<Avatar
variant="rounded"
sx={{
width: 56,
height: 56,
flex: '0 0 auto',
mr: 2,
}}
alt={name}
src={requestManager.getValidImgUrlFor(iconUrl)}
/>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="h5" component="h2">
{name}
</Typography>
<Typography variant="caption" display="block">
{translateExtensionLanguage(lang)}
</Typography>
</Box>
</Box>
<Chip sx={{ borderRadius: '5px' }} size="small" label={mangaCount} />
</CardContent>
</CardActionArea>
</Card>
);

View File

@@ -15,6 +15,8 @@ import { useTranslation } from 'react-i18next';
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
import Label from '@mui/icons-material/Label';
import { useMemo, useState } from 'react';
import SyncAltIcon from '@mui/icons-material/SyncAlt';
import { Link, useNavigate } from 'react-router-dom';
import { TManga } from '@/typings.ts';
import { actionToTranslationKey, MangaAction, MangaDownloadInfo, Mangas, MangaUnreadInfo } from '@/lib/data/Mangas.ts';
import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts';
@@ -28,7 +30,7 @@ const ACTION_DISABLES_SELECTION_MODE: MangaAction[] = ['remove_from_library'] as
type BaseProps = { onClose: (selectionModeState: boolean) => void; setHideMenu: (hide: boolean) => void };
export type SingleModeProps = {
manga: Pick<TManga, 'id'> & MangaDownloadInfo & MangaUnreadInfo;
manga: Pick<TManga, 'id' | 'title' | 'source'> & MangaDownloadInfo & MangaUnreadInfo;
handleSelection?: SelectableCollectionReturnType<TManga['id']>['handleSelection'];
};
@@ -49,6 +51,8 @@ export const MangaActionMenuItems = ({
}: Props) => {
const { t } = useTranslation();
const navigate = useNavigate();
const [isCategorySelectOpen, setIsCategorySelectOpen] = useState(false);
const isSingleMode = !!manga;
@@ -129,6 +133,23 @@ export const MangaActionMenuItems = ({
title={getMenuItemTitle('mark_as_unread', readMangas.length)}
/>
)}
{isSingleMode && (
<Link
to={`/migrate/source/${manga?.source?.id}/manga/${manga?.id}/search?query=${manga?.title}`}
state={{ mangaTitle: manga?.title }}
style={{ textDecoration: 'none', color: 'inherit' }}
>
<MenuItem
onClick={() =>
navigate(
`/migrate/source/${manga?.source?.id}/manga/${manga?.id}/search?query=${manga?.title}`,
)
}
Icon={SyncAltIcon}
title={getMenuItemTitle('migrate', selectedMangas.length)}
/>
</Link>
)}
<MenuItem
onClick={() => {
setIsCategorySelectOpen(true);

View File

@@ -21,6 +21,8 @@ import {
} from '@mui/material';
import React, { useState } 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';
@@ -32,6 +34,7 @@ interface IProps {
export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isLargeScreen = useMediaQuery(theme.breakpoints.up('sm'));
@@ -57,6 +60,17 @@ export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
<Refresh />
</IconButton>
</Tooltip>
<Tooltip title={t('global.button.migrate')}>
<Link
to={`/migrate/source/${manga.source?.id}/manga/${manga.id}/search?query=${manga.title}`}
state={{ mangaTitle: manga.title }}
style={{ textDecoration: 'none', color: 'inherit' }}
>
<IconButton disabled={refreshing}>
<SyncAltIcon />
</IconButton>
</Link>
</Tooltip>
{manga.inLibrary && (
<Tooltip title={t('manga.label.edit_categories')}>
<IconButton

View File

@@ -158,7 +158,7 @@ export const CategoriesInclusionSetting = (props: CategoriesInclusionSettingProp
<>
<ListItemButton onClick={() => setIsDialogOpen(true)}>
<ListItemText
primary={t('category.title.categories')}
primary={t('category.title.category_other')}
secondary={
<>
<span>
@@ -179,7 +179,7 @@ export const CategoriesInclusionSetting = (props: CategoriesInclusionSettingProp
<Dialog open={isDialogOpen} onClose={closeDialog}>
<DialogContent>
<DialogTitle sx={{ paddingLeft: 0 }}>{t('category.title.categories')}</DialogTitle>
<DialogTitle sx={{ paddingLeft: 0 }}>{t('category.title.category_other')}</DialogTitle>
{dialogText && <DialogContentText sx={{ paddingBottom: '10px' }}>{dialogText}</DialogContentText>}
<CheckboxContainer>
{dialogCategories.map((category) => (

View File

@@ -10,17 +10,18 @@ import { IconButton, Menu, MenuItem, FormControlLabel, Radio, Tooltip } from '@m
import React from 'react';
import ViewModuleIcon from '@mui/icons-material/ViewModule';
import { useTranslation } from 'react-i18next';
import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import { GridLayout } from '@/components/context/LibraryOptionsContext';
// TODO: clean up this to use a FormControl, and remove dependency on name o radio button
export function SourceGridLayout() {
export function GridLayouts({
gridLayout,
onChange,
}: {
gridLayout: GridLayout;
onChange: (gridLayout: GridLayout) => void;
}) {
const { t } = useTranslation();
const {
options: { SourcegridLayout },
setOptions,
} = useLibraryOptionsContext();
const [anchorEl, setAnchorEl] = React.useState(null);
const open = Boolean(anchorEl);
const handleClick = (event: any) => {
@@ -30,10 +31,8 @@ export function SourceGridLayout() {
setAnchorEl(null);
};
function setGridContextOptions(e: React.ChangeEvent<HTMLInputElement>, checked: boolean) {
if (checked) {
setOptions((prev: any) => ({ ...prev, SourcegridLayout: parseInt(e.target.name, 10) }));
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
onChange(parseInt(e.target.name, 10));
}
return (
@@ -64,8 +63,8 @@ export function SourceGridLayout() {
control={
<Radio
name={GridLayout.Compact.toString()}
checked={SourcegridLayout === GridLayout.Compact || SourcegridLayout === undefined}
onChange={setGridContextOptions}
checked={gridLayout === GridLayout.Compact}
onChange={handleChange}
/>
}
/>
@@ -76,8 +75,8 @@ export function SourceGridLayout() {
control={
<Radio
name={GridLayout.Comfortable.toString()}
checked={SourcegridLayout === GridLayout.Comfortable}
onChange={setGridContextOptions}
checked={gridLayout === GridLayout.Comfortable}
onChange={handleChange}
/>
}
/>
@@ -88,8 +87,8 @@ export function SourceGridLayout() {
control={
<Radio
name={GridLayout.List.toString()}
checked={SourcegridLayout === GridLayout.List}
onChange={setGridContextOptions}
checked={gridLayout === GridLayout.List}
onChange={handleChange}
/>
}
/>

View File

@@ -0,0 +1,23 @@
/*
* 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 { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import { GridLayouts } from '@/components/source/GridLayouts.tsx';
export function SourceGridLayout() {
const {
options: { SourcegridLayout },
setOptions,
} = useLibraryOptionsContext();
function setGridContextOptions(gridLayout: GridLayout) {
setOptions((prev: any) => ({ ...prev, SourcegridLayout: gridLayout }));
}
return <GridLayouts gridLayout={SourcegridLayout} onChange={setGridContextOptions} />;
}