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:
@@ -37,6 +37,7 @@ import { ServerUpdateChecker } from '@/components/settings/ServerUpdateChecker.t
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { ExtensionSettings } from '@/screens/settings/ExtensionSettings.tsx';
|
||||
import { WebUISettings } from '@/screens/settings/WebUISettings.tsx';
|
||||
import { Migrate } from '@/screens/Migrate.tsx';
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// Adds messages only in a dev environment
|
||||
@@ -120,6 +121,10 @@ export const App: React.FC = () => (
|
||||
<Route path="updates" element={<Updates />} />
|
||||
<Route path="extensions" element={<Extensions />} />
|
||||
<Route path="browse" element={<Browse />} />
|
||||
<Route path="migrate/source/:sourceId">
|
||||
<Route index element={<Migrate />} />
|
||||
<Route path="manga/:mangaId/search" element={<SearchAll />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Container>
|
||||
<Routes>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
97
src/components/MigrateDialog.tsx
Normal file
97
src/components/MigrateDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
60
src/components/MigrationCard.tsx
Normal file
60
src/components/MigrationCard.tsx
Normal 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>
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
23
src/components/source/SourceGridLayout.tsx
Normal file
23
src/components/source/SourceGridLayout.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -22,10 +22,6 @@
|
||||
"category_name": "Category Name",
|
||||
"use_as_default_category": "Default category when adding new manga to the library"
|
||||
},
|
||||
"title": {
|
||||
"categories": "Categories",
|
||||
"set_categories": "Set categories"
|
||||
},
|
||||
"settings": {
|
||||
"inclusion": {
|
||||
"label": {
|
||||
@@ -33,6 +29,11 @@
|
||||
"include": "Include: {{includedCategoriesText}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"category_one": "Category",
|
||||
"category_other": "Categories",
|
||||
"set_categories": "Set categories"
|
||||
}
|
||||
},
|
||||
"chapter": {
|
||||
@@ -161,15 +162,15 @@
|
||||
},
|
||||
"settings": {
|
||||
"auto_download": {
|
||||
"label": {
|
||||
"ignore_with_unread_chapters": "Ignore automatic chapter downloads for entries with unread chapters",
|
||||
"new_chapters": "Download new chapters"
|
||||
},
|
||||
"categories": {
|
||||
"label": {
|
||||
"include_in_download": "Entries in excluded categories will not be downloaded even if they are also in included categories"
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"ignore_with_unread_chapters": "Ignore automatic chapter downloads for entries with unread chapters",
|
||||
"new_chapters": "Download new chapters"
|
||||
},
|
||||
"title": "Auto-download"
|
||||
},
|
||||
"delete_chapters": {
|
||||
@@ -289,10 +290,12 @@
|
||||
"browse": "Browse",
|
||||
"cancel": "Cancel",
|
||||
"clear": "Clear",
|
||||
"copy": "Copy",
|
||||
"deselect": "Deselect",
|
||||
"edit": "Edit",
|
||||
"filter": "Filter",
|
||||
"latest": "Latest",
|
||||
"migrate": "Migrate",
|
||||
"ok": "Ok",
|
||||
"open_site": "Open Site",
|
||||
"options": "Options",
|
||||
@@ -549,6 +552,12 @@
|
||||
"success_other": "Removed {{count}} manga from the library"
|
||||
}
|
||||
}
|
||||
},
|
||||
"migrate": {
|
||||
"label": {
|
||||
"error": "Could not migrate manga",
|
||||
"success": "Successfully migrated manga"
|
||||
}
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
@@ -573,6 +582,23 @@
|
||||
"title_one": "Manga",
|
||||
"title_other": "Manga"
|
||||
},
|
||||
"migrate": {
|
||||
"dialog": {
|
||||
"action": {
|
||||
"button": {
|
||||
"show_entry": "Show entry"
|
||||
}
|
||||
},
|
||||
"title": "Select data to include"
|
||||
},
|
||||
"label": {
|
||||
"info": "Migrating manga…"
|
||||
},
|
||||
"search": {
|
||||
"title": "$t(migrate.title) \"{{title}}\""
|
||||
},
|
||||
"title": "Migrate"
|
||||
},
|
||||
"reader": {
|
||||
"button": {
|
||||
"close_menu": "Close menu",
|
||||
@@ -605,10 +631,10 @@
|
||||
"load_next_chapter": "Load next chapter at ending",
|
||||
"offset_first_page": "Offset first page",
|
||||
"reader_type": "Reader type",
|
||||
"reader_width": "Reader width",
|
||||
"show_page_number": "Show page number",
|
||||
"skip_dup_chapters": "Skip duplicate chapters",
|
||||
"static_navigation": "Static navigation",
|
||||
"reader_width": "Reader width"
|
||||
"static_navigation": "Static navigation"
|
||||
},
|
||||
"reader_type": {
|
||||
"label": {
|
||||
@@ -926,4 +952,4 @@
|
||||
},
|
||||
"title": "Updates"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ export type ChapterIdInfo = Pick<TChapter, 'id'>;
|
||||
export type ChapterDownloadInfo = ChapterIdInfo & Pick<TChapter, 'isDownloaded'>;
|
||||
export type ChapterBookmarkInfo = ChapterIdInfo & Pick<TChapter, 'isBookmarked'>;
|
||||
export type ChapterReadInfo = ChapterIdInfo & Pick<TChapter, 'isRead'>;
|
||||
export type ChapterNumberInfo = ChapterIdInfo & Pick<TChapter, 'chapterNumber'>;
|
||||
|
||||
export class Chapters {
|
||||
static getIds(chapters: { id: number }[]): number[] {
|
||||
@@ -130,6 +131,23 @@ export class Chapters {
|
||||
return chapters.filter((chapter) => !Chapters.isRead(chapter));
|
||||
}
|
||||
|
||||
static getMatchingChapterNumberChapters<Chapter extends ChapterNumberInfo>(
|
||||
chaptersA: Chapter[],
|
||||
chaptersB: Chapter[],
|
||||
): [ChapterA: Chapter, ChapterB: Chapter][] {
|
||||
return chaptersA
|
||||
.map((chapterA) => {
|
||||
const matchingChapter = chaptersB.find((chapterB) => chapterA.chapterNumber === chapterB.chapterNumber);
|
||||
|
||||
if (!matchingChapter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [chapterA, matchingChapter];
|
||||
})
|
||||
.filter((matchingChapters) => matchingChapters !== null) as [Chapter, Chapter][];
|
||||
}
|
||||
|
||||
static async download(chapterIds: number[]): Promise<void> {
|
||||
return Chapters.executeAction(
|
||||
'download',
|
||||
|
||||
@@ -12,6 +12,8 @@ import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import {
|
||||
ChapterConditionInput,
|
||||
GetMangasChapterIdsWithStateQuery,
|
||||
GetMangaToMigrateQuery,
|
||||
GetMangaToMigrateToFetchMutation,
|
||||
UpdateMangaCategoriesPatchInput,
|
||||
} from '@/lib/graphql/generated/graphql.ts';
|
||||
import { Chapters } from '@/lib/data/Chapters.ts';
|
||||
@@ -23,7 +25,8 @@ export type MangaAction =
|
||||
| 'mark_as_read'
|
||||
| 'mark_as_unread'
|
||||
| 'remove_from_library'
|
||||
| 'change_categories';
|
||||
| 'change_categories'
|
||||
| 'migrate';
|
||||
|
||||
export const actionToTranslationKey: {
|
||||
[key in MangaAction]: {
|
||||
@@ -83,11 +86,38 @@ export const actionToTranslationKey: {
|
||||
success: 'manga.action.category.label.success',
|
||||
error: 'manga.action.category.label.error',
|
||||
},
|
||||
migrate: {
|
||||
action: {
|
||||
single: 'global.button.migrate',
|
||||
selected: 'global.button.migrate', // not supported
|
||||
},
|
||||
success: 'manga.action.migrate.label.success',
|
||||
error: 'manga.action.migrate.label.error',
|
||||
},
|
||||
};
|
||||
|
||||
export type MangaChapterCountInfo = { chapters: Pick<TManga['chapters'], 'totalCount'> };
|
||||
export type MangaDownloadInfo = Pick<TManga, 'downloadCount'> & MangaChapterCountInfo;
|
||||
export type MangaUnreadInfo = Pick<TManga, 'unreadCount'> & MangaChapterCountInfo;
|
||||
|
||||
export type MigrateMode = 'copy' | 'migrate';
|
||||
|
||||
type MarkAsReadOptions = { wasManuallyMarkedAsRead: boolean };
|
||||
type ChangeCategoriesOptions = { changeCategoriesPatch: UpdateMangaCategoriesPatchInput };
|
||||
type MigrateOptions = {
|
||||
mangaIdToMigrateTo: number;
|
||||
mode: MigrateMode;
|
||||
migrateChapters?: boolean;
|
||||
migrateCategories?: boolean;
|
||||
};
|
||||
type PerformActionOptions<Action extends MangaAction> = Action extends 'mark_as_read'
|
||||
? MarkAsReadOptions & PropertiesNever<ChangeCategoriesOptions> & PropertiesNever<MigrateOptions>
|
||||
: Action extends 'change_categories'
|
||||
? PropertiesNever<MarkAsReadOptions> & ChangeCategoriesOptions & PropertiesNever<MigrateOptions>
|
||||
: Action extends 'migrate'
|
||||
? PropertiesNever<MarkAsReadOptions> & PropertiesNever<ChangeCategoriesOptions> & MigrateOptions
|
||||
: Partial<MarkAsReadOptions> & Partial<ChangeCategoriesOptions> & Partial<MigrateOptions>;
|
||||
|
||||
export class Mangas {
|
||||
static getIds(mangas: { id: number }[]): number[] {
|
||||
return mangas.map((manga) => manga.id);
|
||||
@@ -185,6 +215,89 @@ export class Mangas {
|
||||
);
|
||||
}
|
||||
|
||||
private static async migrateChapters(
|
||||
mangaToMigrate: GetMangaToMigrateQuery['manga'],
|
||||
mangaToMigrateToInfo: GetMangaToMigrateToFetchMutation,
|
||||
): Promise<void> {
|
||||
if (!mangaToMigrate.chapters || !mangaToMigrateToInfo.fetchChapters?.chapters) {
|
||||
throw new Error('Chapters are missing');
|
||||
}
|
||||
|
||||
const chaptersToMigrate = mangaToMigrate.chapters.nodes;
|
||||
|
||||
const chaptersToMigrateTo = mangaToMigrateToInfo.fetchChapters?.chapters;
|
||||
const migratableChapters = Chapters.getMatchingChapterNumberChapters(chaptersToMigrate, chaptersToMigrateTo);
|
||||
|
||||
const readChapters: number[] = [];
|
||||
const bookmarkedChapters: number[] = [];
|
||||
|
||||
migratableChapters.forEach(([chapterToMigrate, chapterToMigrateTo]) => {
|
||||
const { isRead, isBookmarked } = chapterToMigrate;
|
||||
|
||||
if (isRead) {
|
||||
readChapters.push(chapterToMigrateTo.id);
|
||||
}
|
||||
|
||||
if (isBookmarked) {
|
||||
bookmarkedChapters.push(chapterToMigrateTo.id);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
requestManager.updateChapters(readChapters, { isRead: true }).response,
|
||||
requestManager.updateChapters(bookmarkedChapters, { isBookmarked: true }).response,
|
||||
]);
|
||||
}
|
||||
|
||||
private static async migrateCategories(
|
||||
mangaToMigrate: GetMangaToMigrateQuery['manga'],
|
||||
mangaToMigrateTo: GetMangaToMigrateToFetchMutation['fetchManga']['manga'],
|
||||
): Promise<void> {
|
||||
if (!mangaToMigrate?.categories) {
|
||||
throw new Error('Categories are missing');
|
||||
}
|
||||
|
||||
requestManager.updateMangasCategories([mangaToMigrateTo.id], {
|
||||
addToCategories: mangaToMigrate.categories.nodes.map((category) => category.id),
|
||||
});
|
||||
}
|
||||
|
||||
static async migrate(
|
||||
mangaId: number,
|
||||
mangaIdToMigrateTo: number,
|
||||
{ mode, migrateChapters, migrateCategories }: Omit<MigrateOptions, 'mangaIdToMigrateTo'>,
|
||||
): Promise<void> {
|
||||
return Mangas.executeAction('migrate', 1, async () => {
|
||||
const [{ data: mangaToMigrateData }, { data: mangaToMigrateToData }] = await Promise.all([
|
||||
requestManager.getMangaToMigrate(mangaId, { migrateChapters, migrateCategories }).response,
|
||||
requestManager.getMangaToMigrateToFetch(mangaIdToMigrateTo, { migrateChapters, migrateCategories })
|
||||
.response,
|
||||
]);
|
||||
|
||||
if (!mangaToMigrateData.manga || !mangaToMigrateToData?.fetchManga.manga) {
|
||||
throw new Error('Mangas::migrate: missing manga data');
|
||||
}
|
||||
|
||||
if (
|
||||
migrateChapters &&
|
||||
(!mangaToMigrateData.manga.chapters || !mangaToMigrateToData.fetchChapters?.chapters)
|
||||
) {
|
||||
throw new Error('Mangas::migrate: missing chapters data');
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
migrateChapters ? Mangas.migrateChapters(mangaToMigrateData.manga, mangaToMigrateToData) : undefined,
|
||||
migrateCategories
|
||||
? Mangas.migrateCategories(mangaToMigrateData.manga, mangaToMigrateToData.fetchManga.manga)
|
||||
: undefined,
|
||||
!mangaToMigrateToData.fetchManga.manga.inLibrary
|
||||
? requestManager.updateManga(mangaIdToMigrateTo, { inLibrary: true }).response
|
||||
: undefined,
|
||||
mode === 'migrate' ? requestManager.updateManga(mangaId, { inLibrary: false }).response : undefined,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
private static async executeAction(
|
||||
action: MangaAction,
|
||||
itemCount: number,
|
||||
@@ -205,11 +318,9 @@ export class Mangas {
|
||||
{
|
||||
wasManuallyMarkedAsRead,
|
||||
changeCategoriesPatch,
|
||||
}: Action extends 'mark_as_read'
|
||||
? { wasManuallyMarkedAsRead: boolean; changeCategoriesPatch?: never }
|
||||
: Action extends 'change_categories'
|
||||
? { wasManuallyMarkedAsRead?: never; changeCategoriesPatch: UpdateMangaCategoriesPatchInput }
|
||||
: { wasManuallyMarkedAsRead?: boolean; changeCategoriesPatch?: UpdateMangaCategoriesPatchInput },
|
||||
mangaIdToMigrateTo,
|
||||
...migrateOptions
|
||||
}: PerformActionOptions<Action>,
|
||||
): Promise<void> {
|
||||
switch (action) {
|
||||
case 'download':
|
||||
@@ -224,6 +335,9 @@ export class Mangas {
|
||||
return Mangas.removeFromLibrary(mangaIds);
|
||||
case 'change_categories':
|
||||
return Mangas.changeCategories(mangaIds, changeCategoriesPatch!);
|
||||
case 'migrate': {
|
||||
return Mangas.migrate(mangaIds[0], mangaIdToMigrateTo!, migrateOptions as unknown as MigrateOptions);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Mangas::performAction: unknown action "${action}"`);
|
||||
}
|
||||
|
||||
@@ -508,7 +508,7 @@ export type PageInfoFieldPolicy = {
|
||||
hasPreviousPage?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
startCursor?: FieldPolicy<any> | FieldReadFunction<any>
|
||||
};
|
||||
export type PartialSettingsTypeKeySpecifier = ('autoDownloadAheadLimit' | 'autoDownloadNewChapters' | 'backupInterval' | 'backupPath' | 'backupTTL' | 'backupTime' | 'basicAuthEnabled' | 'basicAuthPassword' | 'basicAuthUsername' | 'debugLogsEnabled' | 'downloadAsCbz' | 'downloadsPath' | 'electronPath' | 'excludeCompleted' | 'excludeEntryWithUnreadChapters' | 'excludeNotStarted' | 'excludeUnreadChapters' | 'extensionRepos' | 'globalUpdateInterval' | 'gqlDebugLogsEnabled' | 'initialOpenInBrowserEnabled' | 'ip' | 'localSourcePath' | 'maxSourcesInParallel' | 'port' | 'socksProxyEnabled' | 'socksProxyHost' | 'socksProxyPort' | 'systemTrayEnabled' | 'updateMangas' | 'webUIChannel' | 'webUIFlavor' | 'webUIInterface' | 'webUIUpdateCheckInterval' | PartialSettingsTypeKeySpecifier)[];
|
||||
export type PartialSettingsTypeKeySpecifier = ('autoDownloadAheadLimit' | 'autoDownloadNewChapters' | 'backupInterval' | 'backupPath' | 'backupTTL' | 'backupTime' | 'basicAuthEnabled' | 'basicAuthPassword' | 'basicAuthUsername' | 'debugLogsEnabled' | 'downloadAsCbz' | 'downloadsPath' | 'electronPath' | 'excludeCompleted' | 'excludeEntryWithUnreadChapters' | 'excludeNotStarted' | 'excludeUnreadChapters' | 'extensionRepos' | 'flareSolverrEnabled' | 'flareSolverrSessionName' | 'flareSolverrSessionTtl' | 'flareSolverrTimeout' | 'flareSolverrUrl' | 'globalUpdateInterval' | 'gqlDebugLogsEnabled' | 'initialOpenInBrowserEnabled' | 'ip' | 'localSourcePath' | 'maxSourcesInParallel' | 'port' | 'socksProxyEnabled' | 'socksProxyHost' | 'socksProxyPort' | 'systemTrayEnabled' | 'updateMangas' | 'webUIChannel' | 'webUIFlavor' | 'webUIInterface' | 'webUIUpdateCheckInterval' | PartialSettingsTypeKeySpecifier)[];
|
||||
export type PartialSettingsTypeFieldPolicy = {
|
||||
autoDownloadAheadLimit?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
autoDownloadNewChapters?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
@@ -528,6 +528,11 @@ export type PartialSettingsTypeFieldPolicy = {
|
||||
excludeNotStarted?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
excludeUnreadChapters?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
extensionRepos?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
flareSolverrEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
flareSolverrSessionName?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
flareSolverrSessionTtl?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
flareSolverrTimeout?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
flareSolverrUrl?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
globalUpdateInterval?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
gqlDebugLogsEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
initialOpenInBrowserEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
@@ -631,7 +636,7 @@ export type SetSettingsPayloadFieldPolicy = {
|
||||
clientMutationId?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
settings?: FieldPolicy<any> | FieldReadFunction<any>
|
||||
};
|
||||
export type SettingsKeySpecifier = ('autoDownloadAheadLimit' | 'autoDownloadNewChapters' | 'backupInterval' | 'backupPath' | 'backupTTL' | 'backupTime' | 'basicAuthEnabled' | 'basicAuthPassword' | 'basicAuthUsername' | 'debugLogsEnabled' | 'downloadAsCbz' | 'downloadsPath' | 'electronPath' | 'excludeCompleted' | 'excludeEntryWithUnreadChapters' | 'excludeNotStarted' | 'excludeUnreadChapters' | 'extensionRepos' | 'globalUpdateInterval' | 'gqlDebugLogsEnabled' | 'initialOpenInBrowserEnabled' | 'ip' | 'localSourcePath' | 'maxSourcesInParallel' | 'port' | 'socksProxyEnabled' | 'socksProxyHost' | 'socksProxyPort' | 'systemTrayEnabled' | 'updateMangas' | 'webUIChannel' | 'webUIFlavor' | 'webUIInterface' | 'webUIUpdateCheckInterval' | SettingsKeySpecifier)[];
|
||||
export type SettingsKeySpecifier = ('autoDownloadAheadLimit' | 'autoDownloadNewChapters' | 'backupInterval' | 'backupPath' | 'backupTTL' | 'backupTime' | 'basicAuthEnabled' | 'basicAuthPassword' | 'basicAuthUsername' | 'debugLogsEnabled' | 'downloadAsCbz' | 'downloadsPath' | 'electronPath' | 'excludeCompleted' | 'excludeEntryWithUnreadChapters' | 'excludeNotStarted' | 'excludeUnreadChapters' | 'extensionRepos' | 'flareSolverrEnabled' | 'flareSolverrSessionName' | 'flareSolverrSessionTtl' | 'flareSolverrTimeout' | 'flareSolverrUrl' | 'globalUpdateInterval' | 'gqlDebugLogsEnabled' | 'initialOpenInBrowserEnabled' | 'ip' | 'localSourcePath' | 'maxSourcesInParallel' | 'port' | 'socksProxyEnabled' | 'socksProxyHost' | 'socksProxyPort' | 'systemTrayEnabled' | 'updateMangas' | 'webUIChannel' | 'webUIFlavor' | 'webUIInterface' | 'webUIUpdateCheckInterval' | SettingsKeySpecifier)[];
|
||||
export type SettingsFieldPolicy = {
|
||||
autoDownloadAheadLimit?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
autoDownloadNewChapters?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
@@ -651,6 +656,11 @@ export type SettingsFieldPolicy = {
|
||||
excludeNotStarted?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
excludeUnreadChapters?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
extensionRepos?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
flareSolverrEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
flareSolverrSessionName?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
flareSolverrSessionTtl?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
flareSolverrTimeout?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
flareSolverrUrl?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
globalUpdateInterval?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
gqlDebugLogsEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
initialOpenInBrowserEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
@@ -668,7 +678,7 @@ export type SettingsFieldPolicy = {
|
||||
webUIInterface?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
webUIUpdateCheckInterval?: FieldPolicy<any> | FieldReadFunction<any>
|
||||
};
|
||||
export type SettingsTypeKeySpecifier = ('autoDownloadAheadLimit' | 'autoDownloadNewChapters' | 'backupInterval' | 'backupPath' | 'backupTTL' | 'backupTime' | 'basicAuthEnabled' | 'basicAuthPassword' | 'basicAuthUsername' | 'debugLogsEnabled' | 'downloadAsCbz' | 'downloadsPath' | 'electronPath' | 'excludeCompleted' | 'excludeEntryWithUnreadChapters' | 'excludeNotStarted' | 'excludeUnreadChapters' | 'extensionRepos' | 'globalUpdateInterval' | 'gqlDebugLogsEnabled' | 'initialOpenInBrowserEnabled' | 'ip' | 'localSourcePath' | 'maxSourcesInParallel' | 'port' | 'socksProxyEnabled' | 'socksProxyHost' | 'socksProxyPort' | 'systemTrayEnabled' | 'updateMangas' | 'webUIChannel' | 'webUIFlavor' | 'webUIInterface' | 'webUIUpdateCheckInterval' | SettingsTypeKeySpecifier)[];
|
||||
export type SettingsTypeKeySpecifier = ('autoDownloadAheadLimit' | 'autoDownloadNewChapters' | 'backupInterval' | 'backupPath' | 'backupTTL' | 'backupTime' | 'basicAuthEnabled' | 'basicAuthPassword' | 'basicAuthUsername' | 'debugLogsEnabled' | 'downloadAsCbz' | 'downloadsPath' | 'electronPath' | 'excludeCompleted' | 'excludeEntryWithUnreadChapters' | 'excludeNotStarted' | 'excludeUnreadChapters' | 'extensionRepos' | 'flareSolverrEnabled' | 'flareSolverrSessionName' | 'flareSolverrSessionTtl' | 'flareSolverrTimeout' | 'flareSolverrUrl' | 'globalUpdateInterval' | 'gqlDebugLogsEnabled' | 'initialOpenInBrowserEnabled' | 'ip' | 'localSourcePath' | 'maxSourcesInParallel' | 'port' | 'socksProxyEnabled' | 'socksProxyHost' | 'socksProxyPort' | 'systemTrayEnabled' | 'updateMangas' | 'webUIChannel' | 'webUIFlavor' | 'webUIInterface' | 'webUIUpdateCheckInterval' | SettingsTypeKeySpecifier)[];
|
||||
export type SettingsTypeFieldPolicy = {
|
||||
autoDownloadAheadLimit?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
autoDownloadNewChapters?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
@@ -688,6 +698,11 @@ export type SettingsTypeFieldPolicy = {
|
||||
excludeNotStarted?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
excludeUnreadChapters?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
extensionRepos?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
flareSolverrEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
flareSolverrSessionName?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
flareSolverrSessionTtl?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
flareSolverrTimeout?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
flareSolverrUrl?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
globalUpdateInterval?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
gqlDebugLogsEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
initialOpenInBrowserEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
@@ -785,7 +800,7 @@ export type TrackRecordNodeListFieldPolicy = {
|
||||
pageInfo?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
totalCount?: FieldPolicy<any> | FieldReadFunction<any>
|
||||
};
|
||||
export type TrackRecordTypeKeySpecifier = ('displayScore' | 'finishDate' | 'id' | 'lastChapterRead' | 'libraryId' | 'manga' | 'mangaId' | 'remoteId' | 'remoteUrl' | 'score' | 'startDate' | 'status' | 'syncId' | 'title' | 'totalChapters' | 'tracker' | TrackRecordTypeKeySpecifier)[];
|
||||
export type TrackRecordTypeKeySpecifier = ('displayScore' | 'finishDate' | 'id' | 'lastChapterRead' | 'libraryId' | 'manga' | 'mangaId' | 'remoteId' | 'remoteUrl' | 'score' | 'startDate' | 'status' | 'title' | 'totalChapters' | 'tracker' | 'trackerId' | TrackRecordTypeKeySpecifier)[];
|
||||
export type TrackRecordTypeFieldPolicy = {
|
||||
displayScore?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
finishDate?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
@@ -799,25 +814,31 @@ export type TrackRecordTypeFieldPolicy = {
|
||||
score?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
startDate?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
status?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
syncId?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
title?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
totalChapters?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
tracker?: FieldPolicy<any> | FieldReadFunction<any>
|
||||
};
|
||||
export type TrackSearchTypeKeySpecifier = ('coverUrl' | 'mediaId' | 'publishingStatus' | 'publishingType' | 'startDate' | 'summary' | 'syncId' | 'title' | 'totalChapters' | 'tracker' | 'trackingUrl' | TrackSearchTypeKeySpecifier)[];
|
||||
export type TrackSearchTypeFieldPolicy = {
|
||||
coverUrl?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
mediaId?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
publishingStatus?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
publishingType?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
startDate?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
summary?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
syncId?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
title?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
totalChapters?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
tracker?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
trackerId?: FieldPolicy<any> | FieldReadFunction<any>
|
||||
};
|
||||
export type TrackSearchTypeKeySpecifier = ('coverUrl' | 'id' | 'publishingStatus' | 'publishingType' | 'remoteId' | 'startDate' | 'summary' | 'title' | 'totalChapters' | 'tracker' | 'trackerId' | 'trackingUrl' | TrackSearchTypeKeySpecifier)[];
|
||||
export type TrackSearchTypeFieldPolicy = {
|
||||
coverUrl?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
id?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
publishingStatus?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
publishingType?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
remoteId?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
startDate?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
summary?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
title?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
totalChapters?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
tracker?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
trackerId?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
trackingUrl?: FieldPolicy<any> | FieldReadFunction<any>
|
||||
};
|
||||
export type TrackStatusTypeKeySpecifier = ('name' | 'value' | TrackStatusTypeKeySpecifier)[];
|
||||
export type TrackStatusTypeFieldPolicy = {
|
||||
name?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
value?: FieldPolicy<any> | FieldReadFunction<any>
|
||||
};
|
||||
export type TrackerEdgeKeySpecifier = ('cursor' | 'node' | TrackerEdgeKeySpecifier)[];
|
||||
export type TrackerEdgeFieldPolicy = {
|
||||
cursor?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
@@ -830,13 +851,15 @@ export type TrackerNodeListFieldPolicy = {
|
||||
pageInfo?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
totalCount?: FieldPolicy<any> | FieldReadFunction<any>
|
||||
};
|
||||
export type TrackerTypeKeySpecifier = ('authUrl' | 'icon' | 'id' | 'isLoggedIn' | 'name' | 'trackRecords' | TrackerTypeKeySpecifier)[];
|
||||
export type TrackerTypeKeySpecifier = ('authUrl' | 'icon' | 'id' | 'isLoggedIn' | 'name' | 'scores' | 'statuses' | 'trackRecords' | TrackerTypeKeySpecifier)[];
|
||||
export type TrackerTypeFieldPolicy = {
|
||||
authUrl?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
icon?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
id?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
isLoggedIn?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
name?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
scores?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
statuses?: FieldPolicy<any> | FieldReadFunction<any>,
|
||||
trackRecords?: FieldPolicy<any> | FieldReadFunction<any>
|
||||
};
|
||||
export type TriStateFilterKeySpecifier = ('default' | 'name' | TriStateFilterKeySpecifier)[];
|
||||
@@ -1351,6 +1374,10 @@ export type StrictTypedTypePolicies = {
|
||||
keyFields?: false | TrackSearchTypeKeySpecifier | (() => undefined | TrackSearchTypeKeySpecifier),
|
||||
fields?: TrackSearchTypeFieldPolicy,
|
||||
},
|
||||
TrackStatusType?: Omit<TypePolicy, "fields" | "keyFields"> & {
|
||||
keyFields?: false | TrackStatusTypeKeySpecifier | (() => undefined | TrackStatusTypeKeySpecifier),
|
||||
fields?: TrackStatusTypeFieldPolicy,
|
||||
},
|
||||
TrackerEdge?: Omit<TypePolicy, "fields" | "keyFields"> & {
|
||||
keyFields?: false | TrackerEdgeKeySpecifier | (() => undefined | TrackerEdgeKeySpecifier),
|
||||
fields?: TrackerEdgeFieldPolicy,
|
||||
|
||||
@@ -52,7 +52,8 @@ export type BackupRestoreStatus = {
|
||||
export type BindTrackInput = {
|
||||
clientMutationId?: InputMaybe<Scalars['String']['input']>;
|
||||
mangaId: Scalars['Int']['input'];
|
||||
track: TrackSearchTypeInput;
|
||||
remoteId: Scalars['LongString']['input'];
|
||||
trackerId: Scalars['Int']['input'];
|
||||
};
|
||||
|
||||
export type BindTrackPayload = {
|
||||
@@ -1369,6 +1370,11 @@ export type PartialSettingsType = Settings & {
|
||||
excludeNotStarted?: Maybe<Scalars['Boolean']['output']>;
|
||||
excludeUnreadChapters?: Maybe<Scalars['Boolean']['output']>;
|
||||
extensionRepos?: Maybe<Array<Scalars['String']['output']>>;
|
||||
flareSolverrEnabled?: Maybe<Scalars['Boolean']['output']>;
|
||||
flareSolverrSessionName?: Maybe<Scalars['String']['output']>;
|
||||
flareSolverrSessionTtl?: Maybe<Scalars['Int']['output']>;
|
||||
flareSolverrTimeout?: Maybe<Scalars['Int']['output']>;
|
||||
flareSolverrUrl?: Maybe<Scalars['String']['output']>;
|
||||
globalUpdateInterval?: Maybe<Scalars['Float']['output']>;
|
||||
gqlDebugLogsEnabled?: Maybe<Scalars['Boolean']['output']>;
|
||||
initialOpenInBrowserEnabled?: Maybe<Scalars['Boolean']['output']>;
|
||||
@@ -1406,6 +1412,11 @@ export type PartialSettingsTypeInput = {
|
||||
excludeNotStarted?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
excludeUnreadChapters?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
extensionRepos?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
flareSolverrEnabled?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
flareSolverrSessionName?: InputMaybe<Scalars['String']['input']>;
|
||||
flareSolverrSessionTtl?: InputMaybe<Scalars['Int']['input']>;
|
||||
flareSolverrTimeout?: InputMaybe<Scalars['Int']['input']>;
|
||||
flareSolverrUrl?: InputMaybe<Scalars['String']['input']>;
|
||||
globalUpdateInterval?: InputMaybe<Scalars['Float']['input']>;
|
||||
gqlDebugLogsEnabled?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
initialOpenInBrowserEnabled?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
@@ -1746,6 +1757,11 @@ export type Settings = {
|
||||
excludeNotStarted?: Maybe<Scalars['Boolean']['output']>;
|
||||
excludeUnreadChapters?: Maybe<Scalars['Boolean']['output']>;
|
||||
extensionRepos?: Maybe<Array<Scalars['String']['output']>>;
|
||||
flareSolverrEnabled?: Maybe<Scalars['Boolean']['output']>;
|
||||
flareSolverrSessionName?: Maybe<Scalars['String']['output']>;
|
||||
flareSolverrSessionTtl?: Maybe<Scalars['Int']['output']>;
|
||||
flareSolverrTimeout?: Maybe<Scalars['Int']['output']>;
|
||||
flareSolverrUrl?: Maybe<Scalars['String']['output']>;
|
||||
globalUpdateInterval?: Maybe<Scalars['Float']['output']>;
|
||||
gqlDebugLogsEnabled?: Maybe<Scalars['Boolean']['output']>;
|
||||
initialOpenInBrowserEnabled?: Maybe<Scalars['Boolean']['output']>;
|
||||
@@ -1784,6 +1800,11 @@ export type SettingsType = Settings & {
|
||||
excludeNotStarted: Scalars['Boolean']['output'];
|
||||
excludeUnreadChapters: Scalars['Boolean']['output'];
|
||||
extensionRepos: Array<Scalars['String']['output']>;
|
||||
flareSolverrEnabled: Scalars['Boolean']['output'];
|
||||
flareSolverrSessionName: Scalars['String']['output'];
|
||||
flareSolverrSessionTtl: Scalars['Int']['output'];
|
||||
flareSolverrTimeout: Scalars['Int']['output'];
|
||||
flareSolverrUrl: Scalars['String']['output'];
|
||||
globalUpdateInterval: Scalars['Float']['output'];
|
||||
gqlDebugLogsEnabled: Scalars['Boolean']['output'];
|
||||
initialOpenInBrowserEnabled: Scalars['Boolean']['output'];
|
||||
@@ -1983,9 +2004,9 @@ export type TrackRecordConditionInput = {
|
||||
score?: InputMaybe<Scalars['Float']['input']>;
|
||||
startDate?: InputMaybe<Scalars['LongString']['input']>;
|
||||
status?: InputMaybe<Scalars['Int']['input']>;
|
||||
syncId?: InputMaybe<Scalars['Int']['input']>;
|
||||
title?: InputMaybe<Scalars['String']['input']>;
|
||||
totalChapters?: InputMaybe<Scalars['Int']['input']>;
|
||||
trackerId?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
export type TrackRecordEdge = Edge & {
|
||||
@@ -2008,9 +2029,9 @@ export type TrackRecordFilterInput = {
|
||||
score?: InputMaybe<DoubleFilterInput>;
|
||||
startDate?: InputMaybe<LongFilterInput>;
|
||||
status?: InputMaybe<IntFilterInput>;
|
||||
syncId?: InputMaybe<IntFilterInput>;
|
||||
title?: InputMaybe<StringFilterInput>;
|
||||
totalChapters?: InputMaybe<IntFilterInput>;
|
||||
trackerId?: InputMaybe<IntFilterInput>;
|
||||
};
|
||||
|
||||
export type TrackRecordNodeList = NodeList & {
|
||||
@@ -2029,9 +2050,9 @@ export enum TrackRecordOrderBy {
|
||||
RemoteId = 'REMOTE_ID',
|
||||
Score = 'SCORE',
|
||||
StartDate = 'START_DATE',
|
||||
SyncId = 'SYNC_ID',
|
||||
Title = 'TITLE',
|
||||
TotalChapters = 'TOTAL_CHAPTERS'
|
||||
TotalChapters = 'TOTAL_CHAPTERS',
|
||||
TrackerId = 'TRACKER_ID'
|
||||
}
|
||||
|
||||
export type TrackRecordType = {
|
||||
@@ -2048,38 +2069,32 @@ export type TrackRecordType = {
|
||||
score: Scalars['Float']['output'];
|
||||
startDate: Scalars['LongString']['output'];
|
||||
status: Scalars['Int']['output'];
|
||||
syncId: Scalars['Int']['output'];
|
||||
title: Scalars['String']['output'];
|
||||
totalChapters: Scalars['Int']['output'];
|
||||
tracker: TrackerType;
|
||||
trackerId: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type TrackSearchType = {
|
||||
__typename?: 'TrackSearchType';
|
||||
coverUrl: Scalars['String']['output'];
|
||||
mediaId: Scalars['LongString']['output'];
|
||||
id: Scalars['Int']['output'];
|
||||
publishingStatus: Scalars['String']['output'];
|
||||
publishingType: Scalars['String']['output'];
|
||||
remoteId: Scalars['LongString']['output'];
|
||||
startDate: Scalars['String']['output'];
|
||||
summary: Scalars['String']['output'];
|
||||
syncId: Scalars['Int']['output'];
|
||||
title: Scalars['String']['output'];
|
||||
totalChapters: Scalars['Int']['output'];
|
||||
tracker: TrackerType;
|
||||
trackerId: Scalars['Int']['output'];
|
||||
trackingUrl: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type TrackSearchTypeInput = {
|
||||
coverUrl: Scalars['String']['input'];
|
||||
mediaId: Scalars['LongString']['input'];
|
||||
publishingStatus: Scalars['String']['input'];
|
||||
publishingType: Scalars['String']['input'];
|
||||
startDate: Scalars['String']['input'];
|
||||
summary: Scalars['String']['input'];
|
||||
syncId: Scalars['Int']['input'];
|
||||
title: Scalars['String']['input'];
|
||||
totalChapters: Scalars['Int']['input'];
|
||||
trackingUrl: Scalars['String']['input'];
|
||||
export type TrackStatusType = {
|
||||
__typename?: 'TrackStatusType';
|
||||
name: Scalars['String']['output'];
|
||||
value: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type TrackerConditionInput = {
|
||||
@@ -2116,6 +2131,8 @@ export type TrackerType = {
|
||||
id: Scalars['Int']['output'];
|
||||
isLoggedIn: Scalars['Boolean']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
scores: Array<Scalars['String']['output']>;
|
||||
statuses: Array<TrackStatusType>;
|
||||
trackRecords: TrackRecordNodeList;
|
||||
};
|
||||
|
||||
@@ -2756,6 +2773,15 @@ export type GetMangaFetchMutationVariables = Exact<{
|
||||
|
||||
export type GetMangaFetchMutation = { __typename?: 'Mutation', fetchManga: { __typename?: 'FetchMangaPayload', clientMutationId?: string | null, manga: { __typename?: 'MangaType', unreadCount: number, downloadCount: number, artist?: string | null, author?: string | null, chaptersLastFetchedAt?: any | null, description?: string | null, genre: Array<string>, id: number, inLibrary: boolean, inLibraryAt: any, initialized: boolean, lastFetchedAt?: any | null, realUrl?: string | null, status: MangaStatus, thumbnailUrl?: string | null, title: string, url: string, lastReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestFetchedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestUploadedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, categories: { __typename?: 'CategoryNodeList', totalCount: number, nodes: Array<{ __typename?: 'CategoryType', default: boolean, id: number, includeInUpdate: IncludeOrExclude, includeInDownload: IncludeOrExclude, name: string, order: number, meta: Array<{ __typename?: 'CategoryMetaType', key: string, value: string }>, mangas: { __typename?: 'MangaNodeList', totalCount: number } }> }, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, meta: Array<{ __typename?: 'MangaMetaType', key: string, value: string }>, source?: { __typename?: 'SourceType', displayName: string, iconUrl: string, id: any, isConfigurable: boolean, isNsfw: boolean, lang: string, name: string, supportsLatest: boolean, extension: { __typename?: 'ExtensionType', pkgName: string, repo?: string | null } } | null } } };
|
||||
|
||||
export type GetMangaToMigrateToFetchMutationVariables = Exact<{
|
||||
id: Scalars['Int']['input'];
|
||||
migrateChapters: Scalars['Boolean']['input'];
|
||||
migrateCategories: Scalars['Boolean']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetMangaToMigrateToFetchMutation = { __typename?: 'Mutation', fetchManga: { __typename?: 'FetchMangaPayload', clientMutationId?: string | null, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, categories?: { __typename?: 'CategoryNodeList', nodes: Array<{ __typename?: 'CategoryType', id: number }> } } }, fetchChapters?: { __typename?: 'FetchChaptersPayload', chapters: Array<{ __typename?: 'ChapterType', id: number, chapterNumber: number, isRead: boolean, isDownloaded: boolean, isBookmarked: boolean, manga: { __typename?: 'MangaType', id: number } }> } };
|
||||
|
||||
export type SetMangaMetadataMutationVariables = Exact<{
|
||||
input: SetMangaMetaInput;
|
||||
}>;
|
||||
@@ -2983,6 +3009,15 @@ export type GetMangaQueryVariables = Exact<{
|
||||
|
||||
export type GetMangaQuery = { __typename?: 'Query', manga: { __typename?: 'MangaType', unreadCount: number, downloadCount: number, artist?: string | null, author?: string | null, chaptersLastFetchedAt?: any | null, description?: string | null, genre: Array<string>, id: number, inLibrary: boolean, inLibraryAt: any, initialized: boolean, lastFetchedAt?: any | null, realUrl?: string | null, status: MangaStatus, thumbnailUrl?: string | null, title: string, url: string, lastReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestFetchedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestUploadedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, categories: { __typename?: 'CategoryNodeList', totalCount: number, nodes: Array<{ __typename?: 'CategoryType', default: boolean, id: number, includeInUpdate: IncludeOrExclude, includeInDownload: IncludeOrExclude, name: string, order: number, meta: Array<{ __typename?: 'CategoryMetaType', key: string, value: string }>, mangas: { __typename?: 'MangaNodeList', totalCount: number } }> }, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, meta: Array<{ __typename?: 'MangaMetaType', key: string, value: string }>, source?: { __typename?: 'SourceType', displayName: string, iconUrl: string, id: any, isConfigurable: boolean, isNsfw: boolean, lang: string, name: string, supportsLatest: boolean, extension: { __typename?: 'ExtensionType', pkgName: string, repo?: string | null } } | null } };
|
||||
|
||||
export type GetMangaToMigrateQueryVariables = Exact<{
|
||||
id: Scalars['Int']['input'];
|
||||
migrateChapters: Scalars['Boolean']['input'];
|
||||
migrateCategories: Scalars['Boolean']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetMangaToMigrateQuery = { __typename?: 'Query', manga: { __typename?: 'MangaType', id: number, inLibrary: boolean, title: string, chapters?: { __typename?: 'ChapterNodeList', totalCount: number, nodes: Array<{ __typename?: 'ChapterType', id: number, chapterNumber: number, isRead: boolean, isDownloaded: boolean, isBookmarked: boolean, manga: { __typename?: 'MangaType', id: number } }> }, categories?: { __typename?: 'CategoryNodeList', nodes: Array<{ __typename?: 'CategoryType', id: number }> } } };
|
||||
|
||||
export type GetMangasQueryVariables = Exact<{
|
||||
after?: InputMaybe<Scalars['Cursor']['input']>;
|
||||
before?: InputMaybe<Scalars['Cursor']['input']>;
|
||||
@@ -2998,6 +3033,13 @@ export type GetMangasQueryVariables = Exact<{
|
||||
|
||||
export type GetMangasQuery = { __typename?: 'Query', mangas: { __typename?: 'MangaNodeList', totalCount: number, nodes: Array<{ __typename?: 'MangaType', unreadCount: number, downloadCount: number, artist?: string | null, author?: string | null, chaptersLastFetchedAt?: any | null, description?: string | null, genre: Array<string>, id: number, inLibrary: boolean, inLibraryAt: any, initialized: boolean, lastFetchedAt?: any | null, realUrl?: string | null, status: MangaStatus, thumbnailUrl?: string | null, title: string, url: string, lastReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestFetchedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestUploadedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, categories: { __typename?: 'CategoryNodeList', totalCount: number, nodes: Array<{ __typename?: 'CategoryType', default: boolean, id: number, includeInUpdate: IncludeOrExclude, includeInDownload: IncludeOrExclude, name: string, order: number, meta: Array<{ __typename?: 'CategoryMetaType', key: string, value: string }>, mangas: { __typename?: 'MangaNodeList', totalCount: number } }> }, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, meta: Array<{ __typename?: 'MangaMetaType', key: string, value: string }>, source?: { __typename?: 'SourceType', displayName: string, iconUrl: string, id: any, isConfigurable: boolean, isNsfw: boolean, lang: string, name: string, supportsLatest: boolean, extension: { __typename?: 'ExtensionType', pkgName: string, repo?: string | null } } | null }>, pageInfo: { __typename?: 'PageInfo', endCursor?: any | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: any | null } } };
|
||||
|
||||
export type GetMigratableSourceMangasQueryVariables = Exact<{
|
||||
sourceId: Scalars['LongString']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetMigratableSourceMangasQuery = { __typename?: 'Query', mangas: { __typename?: 'MangaNodeList', nodes: Array<{ __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null, source?: { __typename?: 'SourceType', id: any } | null, categories: { __typename?: 'CategoryNodeList', nodes: Array<{ __typename?: 'CategoryType', id: number }> } }> } };
|
||||
|
||||
export type GetAboutQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
@@ -3035,6 +3077,11 @@ export type GetSourcesQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
export type GetSourcesQuery = { __typename?: 'Query', sources: { __typename?: 'SourceNodeList', nodes: Array<{ __typename?: 'SourceType', displayName: string, iconUrl: string, id: any, isConfigurable: boolean, isNsfw: boolean, lang: string, name: string, supportsLatest: boolean, extension: { __typename?: 'ExtensionType', pkgName: string, repo?: string | null } }> } };
|
||||
|
||||
export type GetMigratableSourcesQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetMigratableSourcesQuery = { __typename?: 'Query', mangas: { __typename?: 'MangaNodeList', nodes: Array<{ __typename?: 'MangaType', sourceId: any, source?: { __typename?: 'SourceType', id: any, name: string, lang: string, iconUrl: string } | null }> } };
|
||||
|
||||
export type GetUpdateStatusQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
|
||||
@@ -48,6 +48,37 @@ export const GET_MANGA_FETCH = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
// makes the server fetch and return the manga
|
||||
export const GET_MANGA_TO_MIGRATE_TO_FETCH = gql`
|
||||
mutation GET_MANGA_TO_MIGRATE_TO_FETCH($id: Int!, $migrateChapters: Boolean!, $migrateCategories: Boolean!) {
|
||||
fetchManga(input: { id: $id }) {
|
||||
clientMutationId
|
||||
manga {
|
||||
id
|
||||
title
|
||||
inLibrary
|
||||
categories @include(if: $migrateCategories) {
|
||||
nodes {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fetchChapters(input: { mangaId: $id }) @include(if: $migrateChapters) {
|
||||
chapters {
|
||||
id
|
||||
manga {
|
||||
id
|
||||
}
|
||||
chapterNumber
|
||||
isRead
|
||||
isDownloaded
|
||||
isBookmarked
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SET_MANGA_METADATA = gql`
|
||||
mutation SET_MANGA_METADATA($input: SetMangaMetaInput!) {
|
||||
setMangaMeta(input: $input) {
|
||||
|
||||
@@ -20,6 +20,35 @@ export const GET_MANGA = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
// returns the current manga from the database
|
||||
export const GET_MANGA_TO_MIGRATE = gql`
|
||||
query GET_MANGA_TO_MIGRATE($id: Int!, $migrateChapters: Boolean!, $migrateCategories: Boolean!) {
|
||||
manga(id: $id) {
|
||||
id
|
||||
inLibrary
|
||||
title
|
||||
chapters @include(if: $migrateChapters) {
|
||||
nodes {
|
||||
id
|
||||
manga {
|
||||
id
|
||||
}
|
||||
chapterNumber
|
||||
isRead
|
||||
isDownloaded
|
||||
isBookmarked
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
categories @include(if: $migrateCategories) {
|
||||
nodes {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// returns the current manga from the database
|
||||
export const GET_MANGAS = gql`
|
||||
${FULL_MANGA_FIELDS}
|
||||
@@ -57,3 +86,23 @@ export const GET_MANGAS = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_MIGRATABLE_SOURCE_MANGAS = gql`
|
||||
query GET_MIGRATABLE_SOURCE_MANGAS($sourceId: LongString!) {
|
||||
mangas(condition: { sourceId: $sourceId, inLibrary: true }) {
|
||||
nodes {
|
||||
id
|
||||
title
|
||||
thumbnailUrl
|
||||
source {
|
||||
id
|
||||
}
|
||||
categories {
|
||||
nodes {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -28,3 +28,19 @@ export const GET_SOURCES = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_MIGRATABLE_SOURCES = gql`
|
||||
query GET_MIGRATABLE_SOURCES {
|
||||
mangas(condition: { inLibrary: true }) {
|
||||
nodes {
|
||||
sourceId
|
||||
source {
|
||||
id
|
||||
name
|
||||
lang
|
||||
iconUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -172,6 +172,14 @@ import {
|
||||
UpdateMangaCategoriesPatchInput,
|
||||
GetWebuiUpdateStatusQuery,
|
||||
GetWebuiUpdateStatusQueryVariables,
|
||||
GetMigratableSourcesQuery,
|
||||
GetMigratableSourcesQueryVariables,
|
||||
GetMigratableSourceMangasQuery,
|
||||
GetMigratableSourceMangasQueryVariables,
|
||||
GetMangaToMigrateQuery,
|
||||
GetMangaToMigrateQueryVariables,
|
||||
GetMangaToMigrateToFetchMutation,
|
||||
GetMangaToMigrateToFetchMutationVariables,
|
||||
} from '@/lib/graphql/generated/graphql.ts';
|
||||
import { GET_GLOBAL_METADATAS } from '@/lib/graphql/queries/GlobalMetadataQuery.ts';
|
||||
import { SET_GLOBAL_METADATA } from '@/lib/graphql/mutations/GlobalMetadataMutation.ts';
|
||||
@@ -187,16 +195,22 @@ import {
|
||||
INSTALL_EXTERNAL_EXTENSION,
|
||||
UPDATE_EXTENSION,
|
||||
} from '@/lib/graphql/mutations/ExtensionMutation.ts';
|
||||
import { GET_SOURCE, GET_SOURCES } from '@/lib/graphql/queries/SourceQuery.ts';
|
||||
import { GET_MIGRATABLE_SOURCES, GET_SOURCE, GET_SOURCES } from '@/lib/graphql/queries/SourceQuery.ts';
|
||||
import {
|
||||
GET_MANGA_FETCH,
|
||||
GET_MANGA_TO_MIGRATE_TO_FETCH,
|
||||
SET_MANGA_METADATA,
|
||||
UPDATE_MANGA,
|
||||
UPDATE_MANGA_CATEGORIES,
|
||||
UPDATE_MANGAS,
|
||||
UPDATE_MANGAS_CATEGORIES,
|
||||
} from '@/lib/graphql/mutations/MangaMutation.ts';
|
||||
import { GET_MANGA, GET_MANGAS } from '@/lib/graphql/queries/MangaQuery.ts';
|
||||
import {
|
||||
GET_MANGA,
|
||||
GET_MANGA_TO_MIGRATE,
|
||||
GET_MANGAS,
|
||||
GET_MIGRATABLE_SOURCE_MANGAS,
|
||||
} from '@/lib/graphql/queries/MangaQuery.ts';
|
||||
import { GET_CATEGORIES, GET_CATEGORY_MANGAS } from '@/lib/graphql/queries/CategoryQuery.ts';
|
||||
import { GET_SOURCE_MANGAS_FETCH, UPDATE_SOURCE_PREFERENCES } from '@/lib/graphql/mutations/SourceMutation.ts';
|
||||
import {
|
||||
@@ -1330,6 +1344,33 @@ export class RequestManager {
|
||||
return this.doRequest(GQLMethod.USE_QUERY, GET_MANGA, { id: Number(mangaId) }, options);
|
||||
}
|
||||
|
||||
public getManga(
|
||||
mangaId: number | string,
|
||||
options?: QueryOptions<GetMangaQueryVariables, GetMangaQuery>,
|
||||
): AbortabaleApolloQueryResponse<GetMangaQuery> {
|
||||
return this.doRequest(GQLMethod.QUERY, GET_MANGA, { id: Number(mangaId) }, options);
|
||||
}
|
||||
|
||||
public getMangaToMigrate(
|
||||
mangaId: number | string,
|
||||
{
|
||||
migrateChapters = false,
|
||||
migrateCategories = false,
|
||||
apolloOptions: options,
|
||||
}: {
|
||||
migrateChapters?: boolean;
|
||||
migrateCategories?: boolean;
|
||||
apolloOptions?: QueryOptions<GetMangaToMigrateQueryVariables, GetMangaToMigrateQuery>;
|
||||
} = {},
|
||||
): AbortabaleApolloQueryResponse<GetMangaToMigrateQuery> {
|
||||
return this.doRequest(
|
||||
GQLMethod.QUERY,
|
||||
GET_MANGA_TO_MIGRATE,
|
||||
{ id: Number(mangaId), migrateChapters, migrateCategories },
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
public getMangaFetch(
|
||||
mangaId: number | string,
|
||||
options?: MutationOptions<GetMangaFetchMutation, GetMangaFetchMutationVariables>,
|
||||
@@ -1346,6 +1387,33 @@ export class RequestManager {
|
||||
);
|
||||
}
|
||||
|
||||
public getMangaToMigrateToFetch(
|
||||
mangaId: number | string,
|
||||
{
|
||||
migrateChapters = false,
|
||||
migrateCategories = false,
|
||||
apolloOptions: options,
|
||||
}: {
|
||||
migrateChapters?: boolean;
|
||||
migrateCategories?: boolean;
|
||||
apolloOptions?: MutationOptions<
|
||||
GetMangaToMigrateToFetchMutation,
|
||||
GetMangaToMigrateToFetchMutationVariables
|
||||
>;
|
||||
} = {},
|
||||
): AbortableApolloMutationResponse<GetMangaToMigrateToFetchMutation> {
|
||||
return this.doRequest<GetMangaToMigrateToFetchMutation, GetMangaToMigrateToFetchMutationVariables>(
|
||||
GQLMethod.MUTATION,
|
||||
GET_MANGA_TO_MIGRATE_TO_FETCH,
|
||||
{
|
||||
id: Number(mangaId),
|
||||
migrateChapters,
|
||||
migrateCategories,
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
public useGetMangas(
|
||||
variables: GetMangasQueryVariables,
|
||||
options?: QueryHookOptions<GetMangasQuery, GetMangasQueryVariables>,
|
||||
@@ -1360,6 +1428,13 @@ export class RequestManager {
|
||||
return this.doRequest(GQLMethod.QUERY, GET_MANGAS, variables, options);
|
||||
}
|
||||
|
||||
public useGetMigratableSourceMangas(
|
||||
sourceId: string,
|
||||
options?: QueryHookOptions<GetMigratableSourceMangasQuery, GetMigratableSourceMangasQueryVariables>,
|
||||
): AbortableApolloUseQueryResponse<GetMigratableSourceMangasQuery, GetMigratableSourceMangasQueryVariables> {
|
||||
return this.doRequest(GQLMethod.USE_QUERY, GET_MIGRATABLE_SOURCE_MANGAS, { sourceId }, options);
|
||||
}
|
||||
|
||||
public getMangaThumbnailUrl(mangaId: number): string {
|
||||
return this.getValidImgUrlFor(`manga/${mangaId}/thumbnail`);
|
||||
}
|
||||
@@ -2182,6 +2257,12 @@ export class RequestManager {
|
||||
): AbortableApolloUseQueryResponse<GetWebuiUpdateStatusQuery, GetWebuiUpdateStatusQueryVariables> {
|
||||
return this.doRequest(GQLMethod.USE_QUERY, GET_WEBUI_UPDATE_STATUS, undefined, options);
|
||||
}
|
||||
|
||||
public useGetMigratableSources(
|
||||
options?: QueryHookOptions<GetMigratableSourcesQuery, GetMigratableSourcesQueryVariables>,
|
||||
): AbortableApolloUseQueryResponse<GetMigratableSourcesQuery, GetMigratableSourcesQueryVariables> {
|
||||
return this.doRequest(GQLMethod.USE_QUERY, GET_MIGRATABLE_SOURCES, undefined, options);
|
||||
}
|
||||
}
|
||||
|
||||
export const requestManager = new RequestManager();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Sources } from '@/screens/Sources';
|
||||
@@ -14,17 +14,25 @@ import { Extensions } from '@/screens/Extensions';
|
||||
import { TabPanel } from '@/components/tabs/TabPanel.tsx';
|
||||
import { TabsWrapper } from '@/components/tabs/TabsWrapper.tsx';
|
||||
import { TabsMenu } from '@/components/tabs/TabsMenu.tsx';
|
||||
import { Migration } from '@/screens/Migration.tsx';
|
||||
import { NavBarContext } from '@/components/context/NavbarContext.tsx';
|
||||
|
||||
export function Browse() {
|
||||
const { t } = useTranslation();
|
||||
const { setTitle } = useContext(NavBarContext);
|
||||
|
||||
const [tabNum, setTabNum] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(t('global.label.browse'));
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<TabsWrapper>
|
||||
<TabsMenu value={tabNum} tabsCount={2} onChange={(e, newTab) => setTabNum(newTab)}>
|
||||
<Tab sx={{ textTransform: 'none' }} label={t('source.title')} />
|
||||
<Tab sx={{ textTransform: 'none' }} label={t('extension.title')} />
|
||||
<Tab sx={{ textTransform: 'none' }} label={t('migrate.title')} />
|
||||
</TabsMenu>
|
||||
<TabPanel index={0} currentIndex={tabNum}>
|
||||
<Sources />
|
||||
@@ -32,6 +40,9 @@ export function Browse() {
|
||||
<TabPanel index={1} currentIndex={tabNum}>
|
||||
<Extensions />
|
||||
</TabPanel>
|
||||
<TabPanel index={2} currentIndex={tabNum}>
|
||||
<Migration />
|
||||
</TabPanel>
|
||||
</TabsWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,6 +142,11 @@ export const DownloadQueue: React.FC = () => {
|
||||
</Tooltip>
|
||||
</>,
|
||||
);
|
||||
|
||||
return () => {
|
||||
setTitle('');
|
||||
setAction(null);
|
||||
};
|
||||
}, [t, status, isQueueEmpty]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -95,6 +95,7 @@ function getExtensionsInfo(extensions: PartialExtension[]): {
|
||||
|
||||
export function Extensions() {
|
||||
const { t } = useTranslation();
|
||||
const { setAction } = useContext(NavBarContext);
|
||||
|
||||
const theme = useTheme();
|
||||
const isMobileWidth = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
@@ -104,7 +105,6 @@ export function Extensions() {
|
||||
const areMultipleReposInUse = (serverSettingsData?.settings.extensionRepos.length ?? 0) > 1;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { setTitle, setAction } = useContext(NavBarContext);
|
||||
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownExtensionLangs', extensionDefaultLangs());
|
||||
const [showNsfw] = useLocalStorage<boolean>('showNsfw', true);
|
||||
const [query] = useQueryParam('query', StringParam);
|
||||
@@ -169,7 +169,6 @@ export function Extensions() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(t('extension.title'));
|
||||
setAction(
|
||||
<>
|
||||
<AppbarSearch />
|
||||
@@ -182,6 +181,10 @@ export function Extensions() {
|
||||
<LangSelect shownLangs={shownLangs} setShownLangs={setShownLangs} allLangs={allLangs} />
|
||||
</>,
|
||||
);
|
||||
|
||||
return () => {
|
||||
setAction(null);
|
||||
};
|
||||
}, [t, shownLangs, allLangs]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -58,6 +58,11 @@ export const Manga: React.FC = () => {
|
||||
useEffect(() => {
|
||||
setTitle(manga?.title ?? t('manga.title'));
|
||||
setAction(null);
|
||||
|
||||
return () => {
|
||||
setTitle('');
|
||||
setAction(null);
|
||||
};
|
||||
}, [t, manga?.title]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -86,6 +91,10 @@ export const Manga: React.FC = () => {
|
||||
{manga && <MangaToolbarMenu manga={manga} onRefresh={refresh} refreshing={refreshing} />}
|
||||
</Stack>,
|
||||
);
|
||||
|
||||
return () => {
|
||||
setAction(null);
|
||||
};
|
||||
}, [t, error, isValidating, refreshing, manga, refresh]);
|
||||
|
||||
if (error && !manga) {
|
||||
|
||||
110
src/screens/Migrate.tsx
Normal file
110
src/screens/Migrate.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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 { useContext, useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import gql from 'graphql-tag';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavBarContext } from '@/components/context/NavbarContext.tsx';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { TMigratableSource } from '@/components/MigrationCard.tsx';
|
||||
import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder.tsx';
|
||||
import { EmptyView } from '@/components/util/EmptyView.tsx';
|
||||
import { MangaGrid } from '@/components/MangaGrid.tsx';
|
||||
import { TPartialManga } from '@/typings.ts';
|
||||
import { GridLayouts } from '@/components/source/GridLayouts.tsx';
|
||||
import { useLocalStorage } from '@/util/useLocalStorage.tsx';
|
||||
import { GridLayout } from '@/components/context/LibraryOptionsContext.tsx';
|
||||
|
||||
export const Migrate = () => {
|
||||
const { t } = useTranslation();
|
||||
const { setTitle, setAction } = useContext(NavBarContext);
|
||||
|
||||
const { sourceId: paramSourceId } = useParams<{ sourceId: string }>();
|
||||
|
||||
const [gridLayout, setGridLayout] = useLocalStorage('migrateGridLayout', GridLayout.List);
|
||||
|
||||
const fragmentSource = requestManager.graphQLClient.client.cache.readFragment<
|
||||
Pick<TMigratableSource, 'id' | 'name'>
|
||||
>({
|
||||
id: requestManager.graphQLClient.client.cache.identify({ __typename: 'SourceType', id: paramSourceId }),
|
||||
fragment: gql`
|
||||
fragment MigratableSource on SourceType {
|
||||
id
|
||||
name
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const [isKnownSource, setIsKnownSource] = useState(fragmentSource !== null ? true : undefined);
|
||||
const {
|
||||
data: migratableSourceData,
|
||||
loading: isSourceLoading,
|
||||
error: sourceError,
|
||||
} = requestManager.useGetSource(paramSourceId, { skip: !!isKnownSource });
|
||||
|
||||
const { sourceId, name } = {
|
||||
sourceId: paramSourceId,
|
||||
name: paramSourceId,
|
||||
...fragmentSource,
|
||||
...migratableSourceData?.source,
|
||||
};
|
||||
|
||||
const {
|
||||
data: migratableSourceMangasData,
|
||||
loading: areMangasLoading,
|
||||
error: mangasError,
|
||||
} = requestManager.useGetMigratableSourceMangas(sourceId, {
|
||||
skip: !isKnownSource,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(name ?? sourceId ?? t('migrate.title'));
|
||||
setAction(<GridLayouts gridLayout={gridLayout} onChange={setGridLayout} />);
|
||||
|
||||
return () => {
|
||||
setTitle('');
|
||||
setAction(null);
|
||||
};
|
||||
}, [t, name, sourceId, gridLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSourceLoading || isKnownSource) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsKnownSource(
|
||||
!!migratableSourceData ||
|
||||
!!sourceError?.message.includes("The field at path '/source' was declared as a non null type"),
|
||||
);
|
||||
}, [isSourceLoading, sourceError]);
|
||||
|
||||
const isLoadingSource = isSourceLoading || (!sourceError && !isKnownSource);
|
||||
const isLoading = isLoadingSource || areMangasLoading;
|
||||
if (isLoading) {
|
||||
return <LoadingPlaceholder />;
|
||||
}
|
||||
|
||||
const hasErrorSource = sourceError && isKnownSource === false;
|
||||
const hasError = hasErrorSource || mangasError;
|
||||
if (hasError) {
|
||||
const error = (hasErrorSource ? sourceError : mangasError)!;
|
||||
return <EmptyView message={t('global.error.label.failed_to_load_data')} messageExtra={error.message} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MangaGrid
|
||||
hasNextPage={false}
|
||||
loadMore={() => {}}
|
||||
isLoading={areMangasLoading}
|
||||
mangas={(migratableSourceMangasData?.mangas.nodes ?? []) as TPartialManga[]}
|
||||
gridLayout={gridLayout}
|
||||
mode="migrate.search"
|
||||
/>
|
||||
);
|
||||
};
|
||||
69
src/screens/Migration.tsx
Normal file
69
src/screens/Migration.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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 { useTranslation } from 'react-i18next';
|
||||
import { useMemo } from 'react';
|
||||
import List from '@mui/material/List';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder.tsx';
|
||||
import { EmptyView } from '@/components/util/EmptyView.tsx';
|
||||
import { GetMigratableSourcesQuery } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { MigrationCard, TMigratableSource } from '@/components/MigrationCard.tsx';
|
||||
import { StyledGroupItemWrapper } from '@/components/virtuoso/StyledGroupItemWrapper.tsx';
|
||||
|
||||
type TMigratableSourcesResult = GetMigratableSourcesQuery['mangas']['nodes'];
|
||||
type TMigratableSources = Record<string, TMigratableSource>;
|
||||
|
||||
const getMigratableSources = (mangas?: TMigratableSourcesResult): TMigratableSources => {
|
||||
if (!mangas) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const uniqueSources: TMigratableSources = {};
|
||||
|
||||
mangas.forEach(({ sourceId, source }) => {
|
||||
const uniqueSource = uniqueSources[sourceId] ?? {
|
||||
...{ id: sourceId, name: sourceId, lang: 'unknown', iconUrl: null, mangaCount: 0, ...source },
|
||||
};
|
||||
|
||||
uniqueSources[sourceId] = {
|
||||
...uniqueSource,
|
||||
mangaCount: uniqueSource.mangaCount + 1,
|
||||
};
|
||||
});
|
||||
|
||||
return uniqueSources;
|
||||
};
|
||||
|
||||
export const Migration = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, loading, error } = requestManager.useGetMigratableSources();
|
||||
const migratableSources = useMemo(() => getMigratableSources(data?.mangas.nodes), [data?.mangas.nodes]);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingPlaceholder />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <EmptyView message={t('global.error.label.failed_to_load_data')} messageExtra={error.message} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<List>
|
||||
{Object.values(migratableSources).map((migratableSource, index) => (
|
||||
<StyledGroupItemWrapper
|
||||
key={migratableSource.id}
|
||||
isLastItem={index === Object.values(migratableSources).length - 1}
|
||||
>
|
||||
<MigrationCard {...migratableSource} />
|
||||
</StyledGroupItemWrapper>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { Card, CardActionArea, Typography } from '@mui/material';
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { StringParam, useQueryParam } from 'use-query-params';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ISource } from '@/typings';
|
||||
@@ -21,6 +21,7 @@ import { LangSelect } from '@/components/navbar/action/LangSelect';
|
||||
import { MangaGrid } from '@/components/MangaGrid';
|
||||
import { useDebounce } from '@/util/useDebounce.ts';
|
||||
import { NavBarContext, useSetDefaultBackTo } from '@/components/context/NavbarContext.tsx';
|
||||
import { MangaCardProps } from '@/components/MangaCard.tsx';
|
||||
|
||||
type SourceLoadingState = { isLoading: boolean; hasResults: boolean; emptySearch: boolean };
|
||||
type SourceToLoadingStateMap = Map<string, SourceLoadingState>;
|
||||
@@ -84,6 +85,7 @@ const SourceSearchPreview = React.memo(
|
||||
onSearchRequestFinished,
|
||||
searchString,
|
||||
emptyQuery,
|
||||
mode,
|
||||
}: {
|
||||
source: ISource;
|
||||
onSearchRequestFinished: (
|
||||
@@ -94,7 +96,7 @@ const SourceSearchPreview = React.memo(
|
||||
) => void;
|
||||
searchString: string | null | undefined;
|
||||
emptyQuery: boolean;
|
||||
}) => {
|
||||
} & Pick<MangaCardProps, 'mode'>) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { id, displayName, lang } = source;
|
||||
@@ -150,6 +152,7 @@ const SourceSearchPreview = React.memo(
|
||||
noFaces
|
||||
message={errorMessage}
|
||||
inLibraryIndicator
|
||||
mode={mode}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -163,6 +166,11 @@ export const SearchAll: React.FC = () => {
|
||||
|
||||
useSetDefaultBackTo('sources');
|
||||
|
||||
const { pathname, state } = useLocation<{ mangaTitle?: string }>();
|
||||
const isMigrateMode = pathname.startsWith('/migrate/source');
|
||||
|
||||
const mangaTitle = state?.mangaTitle;
|
||||
|
||||
const [query] = useQueryParam('query', StringParam);
|
||||
const searchString = useDebounce(query, TRIGGER_SEARCH_THRESHOLD);
|
||||
|
||||
@@ -203,7 +211,7 @@ export const SearchAll: React.FC = () => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(t('search.title.global_search'));
|
||||
setTitle(t(isMigrateMode ? 'migrate.search.title' : 'search.title.global_search', { title: mangaTitle }));
|
||||
setAction(
|
||||
<>
|
||||
<AppbarSearch autoOpen />
|
||||
@@ -215,6 +223,11 @@ export const SearchAll: React.FC = () => {
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
return () => {
|
||||
setTitle('');
|
||||
setAction(null);
|
||||
};
|
||||
}, [t, shownLangs, setShownLangs, sources]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -234,6 +247,7 @@ export const SearchAll: React.FC = () => {
|
||||
onSearchRequestFinished={updateSourceLoadingState}
|
||||
searchString={searchString}
|
||||
emptyQuery={!query}
|
||||
mode={isMigrateMode ? 'migrate.select' : undefined}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -43,6 +43,11 @@ export function Settings() {
|
||||
useEffect(() => {
|
||||
setTitle(t('settings.title'));
|
||||
setAction(null);
|
||||
|
||||
return () => {
|
||||
setTitle('');
|
||||
setAction(null);
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const { darkTheme, setDarkTheme } = useContext(DarkTheme);
|
||||
@@ -68,7 +73,7 @@ export function Settings() {
|
||||
<ListItemIcon>
|
||||
<ListAltIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('category.title.categories')} />
|
||||
<ListItemText primary={t('category.title.category_other')} />
|
||||
</ListItemLink>
|
||||
<ListItemLink to="/settings/defaultReaderSettings">
|
||||
<ListItemIcon>
|
||||
|
||||
@@ -43,6 +43,11 @@ export function SourceConfigure() {
|
||||
useEffect(() => {
|
||||
setTitle(t('source.configuration.title'));
|
||||
setAction(null);
|
||||
|
||||
return () => {
|
||||
setTitle('');
|
||||
setAction(null);
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const { sourceId } = useParams<{ sourceId: string }>();
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from '@/lib/requests/RequestManager.ts';
|
||||
import { useDebounce } from '@/util/useDebounce.ts';
|
||||
import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
|
||||
import { SourceGridLayout } from '@/components/source/GridLayouts';
|
||||
import { SourceGridLayout } from '@/components/source/SourceGridLayout';
|
||||
import { AppbarSearch } from '@/components/util/AppbarSearch';
|
||||
import { SourceOptions } from '@/components/source/SourceOptions';
|
||||
import { SourceMangaGrid } from '@/components/source/SourceMangaGrid';
|
||||
@@ -366,6 +366,11 @@ export function SourceMangas() {
|
||||
)}
|
||||
</>,
|
||||
);
|
||||
|
||||
return () => {
|
||||
setTitle('');
|
||||
setAction(null);
|
||||
};
|
||||
}, [t, source]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -48,7 +48,7 @@ function groupByLang(sources: ISource[]) {
|
||||
|
||||
export function Sources() {
|
||||
const { t } = useTranslation();
|
||||
const { setTitle, setAction } = useContext(NavBarContext);
|
||||
const { setAction } = useContext(NavBarContext);
|
||||
|
||||
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownSourceLangs', sourceDefualtLangs());
|
||||
const [showNsfw] = useLocalStorage<boolean>('showNsfw', true);
|
||||
@@ -84,7 +84,6 @@ export function Sources() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(t('source.title'));
|
||||
setAction(
|
||||
<>
|
||||
<Tooltip title={t('search.title.global_search')}>
|
||||
@@ -100,6 +99,10 @@ export function Sources() {
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
return () => {
|
||||
setAction(null);
|
||||
};
|
||||
}, [t, shownLangs, sources]);
|
||||
|
||||
if (isLoading) return <LoadingPlaceholder />;
|
||||
|
||||
@@ -104,8 +104,12 @@ export const Updates: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(t('updates.title'));
|
||||
|
||||
setAction(<UpdateChecker />);
|
||||
|
||||
return () => {
|
||||
setTitle('');
|
||||
setAction(null);
|
||||
};
|
||||
}, [t, lastUpdateTimestamp]);
|
||||
|
||||
const downloadForChapter = (chapter: TChapter) => {
|
||||
|
||||
@@ -190,6 +190,11 @@ export function About() {
|
||||
useEffect(() => {
|
||||
setTitle(t('settings.about.title'));
|
||||
setAction(null);
|
||||
|
||||
return () => {
|
||||
setTitle('');
|
||||
setAction(null);
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
useSetDefaultBackTo('settings');
|
||||
|
||||
@@ -62,6 +62,11 @@ export function Backup() {
|
||||
useEffect(() => {
|
||||
setTitle(t('settings.backup.title'));
|
||||
setAction(null);
|
||||
|
||||
return () => {
|
||||
setTitle('');
|
||||
setAction(null);
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
useSetDefaultBackTo('settings');
|
||||
|
||||
@@ -48,8 +48,13 @@ export function Categories() {
|
||||
|
||||
const { setTitle, setAction } = useContext(NavBarContext);
|
||||
useEffect(() => {
|
||||
setTitle(t('category.title.categories'));
|
||||
setTitle(t('category.title.category_other'));
|
||||
setAction(null);
|
||||
|
||||
return () => {
|
||||
setTitle('');
|
||||
setAction(null);
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const { data } = requestManager.useGetCategories();
|
||||
|
||||
@@ -28,6 +28,11 @@ export function DefaultReaderSettings() {
|
||||
useEffect(() => {
|
||||
setTitle(t('reader.settings.title.default_reader_settings'));
|
||||
setAction(null);
|
||||
|
||||
return () => {
|
||||
setTitle('');
|
||||
setAction(null);
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const { metadata, settings, loading } = useDefaultReaderSettings();
|
||||
|
||||
@@ -48,6 +48,11 @@ export const DownloadSettings = () => {
|
||||
useEffect(() => {
|
||||
setTitle(t('download.settings.title'));
|
||||
setAction(null);
|
||||
|
||||
return () => {
|
||||
setTitle('');
|
||||
setAction(null);
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const { data } = requestManager.useGetServerSettings();
|
||||
|
||||
@@ -46,6 +46,11 @@ export function LibrarySettings() {
|
||||
useEffect(() => {
|
||||
setTitle(t('library.settings.title'));
|
||||
setAction(null);
|
||||
|
||||
return () => {
|
||||
setTitle('');
|
||||
setAction(null);
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
useSetDefaultBackTo('settings');
|
||||
|
||||
@@ -55,6 +55,11 @@ export const ServerSettings = () => {
|
||||
useEffect(() => {
|
||||
setTitle(t('settings.server.title.settings'));
|
||||
setAction(null);
|
||||
|
||||
return () => {
|
||||
setTitle('');
|
||||
setAction(null);
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const { data } = requestManager.useGetServerSettings();
|
||||
|
||||
@@ -107,6 +107,11 @@ export const WebUISettings = () => {
|
||||
useEffect(() => {
|
||||
setTitle(t('settings.webui.title.settings'));
|
||||
setAction(null);
|
||||
|
||||
return () => {
|
||||
setTitle('');
|
||||
setAction(null);
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const { data } = requestManager.useGetServerSettings();
|
||||
|
||||
Reference in New Issue
Block a user