From e0c5e0521dbb56e3cfa2d7b3738908acf250feab Mon Sep 17 00:00:00 2001
From: schroda <50052685+schroda@users.noreply.github.com>
Date: Fri, 26 Jan 2024 20:50:51 +0100
Subject: [PATCH] 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
---
src/App.tsx | 5 +
src/components/MangaCard.tsx | 592 ++++++++++--------
src/components/MangaGrid.tsx | 13 +-
src/components/MigrateDialog.tsx | 97 +++
src/components/MigrationCard.tsx | 60 ++
src/components/manga/MangaActionMenuItems.tsx | 23 +-
src/components/manga/MangaToolbarMenu.tsx | 14 +
.../settings/CategoriesInclusionSetting.tsx | 4 +-
src/components/source/GridLayouts.tsx | 33 +-
src/components/source/SourceGridLayout.tsx | 23 +
src/i18n/locale/en.json | 48 +-
src/lib/data/Chapters.ts | 18 +
src/lib/data/Mangas.ts | 126 +++-
src/lib/graphql/generated/apollo-helpers.ts | 65 +-
src/lib/graphql/generated/graphql.ts | 85 ++-
src/lib/graphql/mutations/MangaMutation.ts | 31 +
src/lib/graphql/queries/MangaQuery.ts | 49 ++
src/lib/graphql/queries/SourceQuery.ts | 16 +
src/lib/requests/RequestManager.ts | 85 ++-
src/screens/Browse.tsx | 13 +-
src/screens/DownloadQueue.tsx | 5 +
src/screens/Extensions.tsx | 7 +-
src/screens/Manga.tsx | 9 +
src/screens/Migrate.tsx | 110 ++++
src/screens/Migration.tsx | 69 ++
src/screens/SearchAll.tsx | 20 +-
src/screens/Settings.tsx | 7 +-
src/screens/SourceConfigure.tsx | 5 +
src/screens/SourceMangas.tsx | 7 +-
src/screens/Sources.tsx | 7 +-
src/screens/Updates.tsx | 6 +-
src/screens/settings/About.tsx | 5 +
src/screens/settings/Backup.tsx | 5 +
src/screens/settings/Categories.tsx | 7 +-
.../settings/DefaultReaderSettings.tsx | 5 +
src/screens/settings/DownloadSettings.tsx | 5 +
src/screens/settings/LibrarySettings.tsx | 5 +
src/screens/settings/ServerSettings.tsx | 5 +
src/screens/settings/WebUISettings.tsx | 5 +
39 files changed, 1329 insertions(+), 365 deletions(-)
create mode 100644 src/components/MigrateDialog.tsx
create mode 100644 src/components/MigrationCard.tsx
create mode 100644 src/components/source/SourceGridLayout.tsx
create mode 100644 src/screens/Migrate.tsx
create mode 100644 src/screens/Migration.tsx
diff --git a/src/App.tsx b/src/App.tsx
index 6868cce7..da2fb48d 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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 = () => (
} />
} />
} />
+
+ } />
+ } />
+
diff --git a/src/components/MangaCard.tsx b/src/components/MangaCard.tsx
index 6c4e91e1..41b3305c 100644
--- a/src/components/MangaCard.tsx
+++ b/src/components/MangaCard.tsx
@@ -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['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 && (
+ setIsMigrateDialogOpen(false)} />
+ )}
+
+ {(popupState) => (
+ <>
+ {
+ 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' }}
+ >
+ 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',
+ },
+ },
+ }}
+ >
+
+
+
+
+ {inLibraryIndicator && inLibrary && (
+
+ {t('manga.button.in_library')}
+
+ )}
+ {showUnreadBadge && (unread ?? 0) > 0 && (
+
+ {unread}
+
+ )}
+ {showDownloadBadge && (downloadCount ?? 0) > 0 && (
+
+ {downloadCount}
+
+ )}
+
+
+
+
+ <>
+ {gridLayout !== GridLayout.Comfortable && (
+ <>
+
+
+ >
+ )}
+
+ {gridLayout !== GridLayout.Comfortable && (
+
+
+ {title}
+
+
+ )}
+
+
+ >
+
+
+ {gridLayout === GridLayout.Comfortable && (
+
+
+ {title}
+
+
+ )}
+
+
+ {!!handleSelection && popupState.isOpen && (
+
+ )}
+ >
+ )}
+
+ >
+ );
+ }
+
+ return (
+ <>
+ {isMigrateDialogOpen && (
+ setIsMigrateDialogOpen(false)} />
+ )}
{(popupState) => (
<>
- {
- if (selected === null) {
- return;
- }
+
+ {
+ if (selected === null) {
+ return;
+ }
- e.preventDefault();
- handleSelection?.(id, !selected);
- }}
- to={mangaLinkTo}
- style={{ textDecoration: 'none' }}
- >
- 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);
}}
>
-
-
+
-
-
- {inLibraryIndicator && inLibrary && (
-
- {t('manga.button.in_library')}
-
- )}
- {showUnreadBadge && (unread ?? 0) > 0 && (
-
- {unread}
-
- )}
- {showDownloadBadge && (downloadCount ?? 0) > 0 && (
-
- {downloadCount}
-
- )}
-
-
-
-
- <>
- {gridLayout !== GridLayout.Comfortable && (
- <>
-
-
- >
+
+ {title}
+
+
+
+
+ {inLibraryIndicator && inLibrary && (
+
+ {t('manga.button.in_library')}
+
)}
-
- {gridLayout !== GridLayout.Comfortable && (
-
-
- {title}
-
-
- )}
-
-
- >
-
-
- {gridLayout === GridLayout.Comfortable && (
-
-
- {title}
-
-
- )}
-
-
+ {showUnreadBadge && unread! > 0 && (
+
+ {unread}
+
+ )}
+ {showDownloadBadge && downloadCount! > 0 && (
+
+ {downloadCount}
+
+ )}
+
+
+
+
+
+
+
{!!handleSelection && popupState.isOpen && (
- );
- }
-
- return (
-
- {(popupState) => (
- <>
-
- {
- if (selected === null) {
- return;
- }
-
- e.preventDefault();
- handleSelection?.(id, !selected);
- }}
- >
-
-
-
-
- {title}
-
-
-
-
- {inLibraryIndicator && inLibrary && (
-
- {t('manga.button.in_library')}
-
- )}
- {showUnreadBadge && unread! > 0 && (
- {unread}
- )}
- {showDownloadBadge && downloadCount! > 0 && (
-
- {downloadCount}
-
- )}
-
-
-
-
-
-
-
- {!!handleSelection && popupState.isOpen && (
-
- )}
- >
- )}
-
+ >
);
};
diff --git a/src/components/MangaGrid.tsx b/src/components/MangaGrid.tsx
index 978dff63..675127c1 100644
--- a/src/components/MangaGrid.tsx
+++ b/src/components/MangaGrid.tsx
@@ -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'],
) => (
);
-type DefaultGridProps = {
+type DefaultGridProps = Pick & {
isLoading: boolean;
mangas: TPartialManga[];
inLibraryIndicator?: boolean;
@@ -80,6 +82,7 @@ const HorizontalGrid = ({
isSelectModeActive,
selectedMangaIds,
handleSelection,
+ mode,
}: DefaultGridProps) => (
))
@@ -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 = (props) => {
isSelectModeActive,
selectedMangaIds,
handleSelection,
+ mode,
} = props;
const [dimensions, setDimensions] = useState(document.documentElement.offsetWidth);
@@ -287,6 +294,7 @@ export const MangaGrid: React.FC = (props) => {
isSelectModeActive={isSelectModeActive}
selectedMangaIds={selectedMangaIds}
handleSelection={handleSelection}
+ mode={mode}
/>
) : (
= (props) => {
isSelectModeActive={isSelectModeActive}
selectedMangaIds={selectedMangaIds}
handleSelection={handleSelection}
+ mode={mode}
/>
)}
diff --git a/src/components/MigrateDialog.tsx b/src/components/MigrateDialog.tsx
new file mode 100644
index 00000000..f57888a6
--- /dev/null
+++ b/src/components/MigrateDialog.tsx
@@ -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 (
+
+ );
+};
diff --git a/src/components/MigrationCard.tsx b/src/components/MigrationCard.tsx
new file mode 100644
index 00000000..cd8e61aa
--- /dev/null
+++ b/src/components/MigrationCard.tsx
@@ -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 & {
+ mangaCount: number;
+};
+
+// TODO - cleanup source/extension components
+export const MigrationCard = ({ id, name, lang, iconUrl, mangaCount }: TMigratableSource) => (
+
+
+
+
+
+
+
+ {name}
+
+
+ {translateExtensionLanguage(lang)}
+
+
+
+
+
+
+
+);
diff --git a/src/components/manga/MangaActionMenuItems.tsx b/src/components/manga/MangaActionMenuItems.tsx
index c0c89bbd..50fec2d6 100644
--- a/src/components/manga/MangaActionMenuItems.tsx
+++ b/src/components/manga/MangaActionMenuItems.tsx
@@ -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 & MangaDownloadInfo & MangaUnreadInfo;
+ manga: Pick & MangaDownloadInfo & MangaUnreadInfo;
handleSelection?: SelectableCollectionReturnType['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 && (
+
+