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 && ( + + {(onClose, setHideMenu) => ( + + )} + + )} + + )} + + + ); + } + + 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 && ( {(onClose, setHideMenu) => ( @@ -284,120 +442,6 @@ export const MangaCard = (props: IProps) => { )} - ); - } - - 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 && ( - - {(onClose, setHideMenu) => ( - - )} - - )} - - )} - + ); }; 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 ( + + {t('migrate.dialog.title')} + + + setIncludeChapters(checked)} + /> + setIncludeCategories(checked)} + /> + + + + + + + + + + + + + + ); +}; 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 && ( + + + navigate( + `/migrate/source/${manga?.source?.id}/manga/${manga?.id}/search?query=${manga?.title}`, + ) + } + Icon={SyncAltIcon} + title={getMenuItemTitle('migrate', selectedMangas.length)} + /> + + )} { setIsCategorySelectOpen(true); diff --git a/src/components/manga/MangaToolbarMenu.tsx b/src/components/manga/MangaToolbarMenu.tsx index e848dd19..81b9ba59 100644 --- a/src/components/manga/MangaToolbarMenu.tsx +++ b/src/components/manga/MangaToolbarMenu.tsx @@ -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) => { + + + + + + + {manga.inLibrary && ( setIsDialogOpen(true)}> @@ -179,7 +179,7 @@ export const CategoriesInclusionSetting = (props: CategoriesInclusionSettingProp - {t('category.title.categories')} + {t('category.title.category_other')} {dialogText && {dialogText}} {dialogCategories.map((category) => ( diff --git a/src/components/source/GridLayouts.tsx b/src/components/source/GridLayouts.tsx index 5226d3e5..ca79e44a 100644 --- a/src/components/source/GridLayouts.tsx +++ b/src/components/source/GridLayouts.tsx @@ -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, checked: boolean) { - if (checked) { - setOptions((prev: any) => ({ ...prev, SourcegridLayout: parseInt(e.target.name, 10) })); - } + function handleChange(e: React.ChangeEvent) { + onChange(parseInt(e.target.name, 10)); } return ( @@ -64,8 +63,8 @@ export function SourceGridLayout() { control={ } /> @@ -76,8 +75,8 @@ export function SourceGridLayout() { control={ } /> @@ -88,8 +87,8 @@ export function SourceGridLayout() { control={ } /> diff --git a/src/components/source/SourceGridLayout.tsx b/src/components/source/SourceGridLayout.tsx new file mode 100644 index 00000000..fce970ce --- /dev/null +++ b/src/components/source/SourceGridLayout.tsx @@ -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 ; +} diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 8b59a0d3..b52c0f19 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -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" } -} +} \ No newline at end of file diff --git a/src/lib/data/Chapters.ts b/src/lib/data/Chapters.ts index c6df91f5..ccf07c59 100644 --- a/src/lib/data/Chapters.ts +++ b/src/lib/data/Chapters.ts @@ -78,6 +78,7 @@ export type ChapterIdInfo = Pick; export type ChapterDownloadInfo = ChapterIdInfo & Pick; export type ChapterBookmarkInfo = ChapterIdInfo & Pick; export type ChapterReadInfo = ChapterIdInfo & Pick; +export type ChapterNumberInfo = ChapterIdInfo & Pick; 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( + 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 { return Chapters.executeAction( 'download', diff --git a/src/lib/data/Mangas.ts b/src/lib/data/Mangas.ts index f60be2db..92ccdaa9 100644 --- a/src/lib/data/Mangas.ts +++ b/src/lib/data/Mangas.ts @@ -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 }; export type MangaDownloadInfo = Pick & MangaChapterCountInfo; export type MangaUnreadInfo = Pick & 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 'mark_as_read' + ? MarkAsReadOptions & PropertiesNever & PropertiesNever + : Action extends 'change_categories' + ? PropertiesNever & ChangeCategoriesOptions & PropertiesNever + : Action extends 'migrate' + ? PropertiesNever & PropertiesNever & MigrateOptions + : Partial & Partial & Partial; + 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 { + 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 { + 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, + ): Promise { + 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, ): Promise { 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}"`); } diff --git a/src/lib/graphql/generated/apollo-helpers.ts b/src/lib/graphql/generated/apollo-helpers.ts index 654bc466..63a794df 100644 --- a/src/lib/graphql/generated/apollo-helpers.ts +++ b/src/lib/graphql/generated/apollo-helpers.ts @@ -508,7 +508,7 @@ export type PageInfoFieldPolicy = { hasPreviousPage?: FieldPolicy | FieldReadFunction, startCursor?: FieldPolicy | FieldReadFunction }; -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 | FieldReadFunction, autoDownloadNewChapters?: FieldPolicy | FieldReadFunction, @@ -528,6 +528,11 @@ export type PartialSettingsTypeFieldPolicy = { excludeNotStarted?: FieldPolicy | FieldReadFunction, excludeUnreadChapters?: FieldPolicy | FieldReadFunction, extensionRepos?: FieldPolicy | FieldReadFunction, + flareSolverrEnabled?: FieldPolicy | FieldReadFunction, + flareSolverrSessionName?: FieldPolicy | FieldReadFunction, + flareSolverrSessionTtl?: FieldPolicy | FieldReadFunction, + flareSolverrTimeout?: FieldPolicy | FieldReadFunction, + flareSolverrUrl?: FieldPolicy | FieldReadFunction, globalUpdateInterval?: FieldPolicy | FieldReadFunction, gqlDebugLogsEnabled?: FieldPolicy | FieldReadFunction, initialOpenInBrowserEnabled?: FieldPolicy | FieldReadFunction, @@ -631,7 +636,7 @@ export type SetSettingsPayloadFieldPolicy = { clientMutationId?: FieldPolicy | FieldReadFunction, settings?: FieldPolicy | FieldReadFunction }; -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 | FieldReadFunction, autoDownloadNewChapters?: FieldPolicy | FieldReadFunction, @@ -651,6 +656,11 @@ export type SettingsFieldPolicy = { excludeNotStarted?: FieldPolicy | FieldReadFunction, excludeUnreadChapters?: FieldPolicy | FieldReadFunction, extensionRepos?: FieldPolicy | FieldReadFunction, + flareSolverrEnabled?: FieldPolicy | FieldReadFunction, + flareSolverrSessionName?: FieldPolicy | FieldReadFunction, + flareSolverrSessionTtl?: FieldPolicy | FieldReadFunction, + flareSolverrTimeout?: FieldPolicy | FieldReadFunction, + flareSolverrUrl?: FieldPolicy | FieldReadFunction, globalUpdateInterval?: FieldPolicy | FieldReadFunction, gqlDebugLogsEnabled?: FieldPolicy | FieldReadFunction, initialOpenInBrowserEnabled?: FieldPolicy | FieldReadFunction, @@ -668,7 +678,7 @@ export type SettingsFieldPolicy = { webUIInterface?: FieldPolicy | FieldReadFunction, webUIUpdateCheckInterval?: FieldPolicy | FieldReadFunction }; -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 | FieldReadFunction, autoDownloadNewChapters?: FieldPolicy | FieldReadFunction, @@ -688,6 +698,11 @@ export type SettingsTypeFieldPolicy = { excludeNotStarted?: FieldPolicy | FieldReadFunction, excludeUnreadChapters?: FieldPolicy | FieldReadFunction, extensionRepos?: FieldPolicy | FieldReadFunction, + flareSolverrEnabled?: FieldPolicy | FieldReadFunction, + flareSolverrSessionName?: FieldPolicy | FieldReadFunction, + flareSolverrSessionTtl?: FieldPolicy | FieldReadFunction, + flareSolverrTimeout?: FieldPolicy | FieldReadFunction, + flareSolverrUrl?: FieldPolicy | FieldReadFunction, globalUpdateInterval?: FieldPolicy | FieldReadFunction, gqlDebugLogsEnabled?: FieldPolicy | FieldReadFunction, initialOpenInBrowserEnabled?: FieldPolicy | FieldReadFunction, @@ -785,7 +800,7 @@ export type TrackRecordNodeListFieldPolicy = { pageInfo?: FieldPolicy | FieldReadFunction, totalCount?: FieldPolicy | FieldReadFunction }; -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 | FieldReadFunction, finishDate?: FieldPolicy | FieldReadFunction, @@ -799,25 +814,31 @@ export type TrackRecordTypeFieldPolicy = { score?: FieldPolicy | FieldReadFunction, startDate?: FieldPolicy | FieldReadFunction, status?: FieldPolicy | FieldReadFunction, - syncId?: FieldPolicy | FieldReadFunction, - title?: FieldPolicy | FieldReadFunction, - totalChapters?: FieldPolicy | FieldReadFunction, - tracker?: FieldPolicy | FieldReadFunction -}; -export type TrackSearchTypeKeySpecifier = ('coverUrl' | 'mediaId' | 'publishingStatus' | 'publishingType' | 'startDate' | 'summary' | 'syncId' | 'title' | 'totalChapters' | 'tracker' | 'trackingUrl' | TrackSearchTypeKeySpecifier)[]; -export type TrackSearchTypeFieldPolicy = { - coverUrl?: FieldPolicy | FieldReadFunction, - mediaId?: FieldPolicy | FieldReadFunction, - publishingStatus?: FieldPolicy | FieldReadFunction, - publishingType?: FieldPolicy | FieldReadFunction, - startDate?: FieldPolicy | FieldReadFunction, - summary?: FieldPolicy | FieldReadFunction, - syncId?: FieldPolicy | FieldReadFunction, title?: FieldPolicy | FieldReadFunction, totalChapters?: FieldPolicy | FieldReadFunction, tracker?: FieldPolicy | FieldReadFunction, + trackerId?: FieldPolicy | FieldReadFunction +}; +export type TrackSearchTypeKeySpecifier = ('coverUrl' | 'id' | 'publishingStatus' | 'publishingType' | 'remoteId' | 'startDate' | 'summary' | 'title' | 'totalChapters' | 'tracker' | 'trackerId' | 'trackingUrl' | TrackSearchTypeKeySpecifier)[]; +export type TrackSearchTypeFieldPolicy = { + coverUrl?: FieldPolicy | FieldReadFunction, + id?: FieldPolicy | FieldReadFunction, + publishingStatus?: FieldPolicy | FieldReadFunction, + publishingType?: FieldPolicy | FieldReadFunction, + remoteId?: FieldPolicy | FieldReadFunction, + startDate?: FieldPolicy | FieldReadFunction, + summary?: FieldPolicy | FieldReadFunction, + title?: FieldPolicy | FieldReadFunction, + totalChapters?: FieldPolicy | FieldReadFunction, + tracker?: FieldPolicy | FieldReadFunction, + trackerId?: FieldPolicy | FieldReadFunction, trackingUrl?: FieldPolicy | FieldReadFunction }; +export type TrackStatusTypeKeySpecifier = ('name' | 'value' | TrackStatusTypeKeySpecifier)[]; +export type TrackStatusTypeFieldPolicy = { + name?: FieldPolicy | FieldReadFunction, + value?: FieldPolicy | FieldReadFunction +}; export type TrackerEdgeKeySpecifier = ('cursor' | 'node' | TrackerEdgeKeySpecifier)[]; export type TrackerEdgeFieldPolicy = { cursor?: FieldPolicy | FieldReadFunction, @@ -830,13 +851,15 @@ export type TrackerNodeListFieldPolicy = { pageInfo?: FieldPolicy | FieldReadFunction, totalCount?: FieldPolicy | FieldReadFunction }; -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 | FieldReadFunction, icon?: FieldPolicy | FieldReadFunction, id?: FieldPolicy | FieldReadFunction, isLoggedIn?: FieldPolicy | FieldReadFunction, name?: FieldPolicy | FieldReadFunction, + scores?: FieldPolicy | FieldReadFunction, + statuses?: FieldPolicy | FieldReadFunction, trackRecords?: FieldPolicy | FieldReadFunction }; export type TriStateFilterKeySpecifier = ('default' | 'name' | TriStateFilterKeySpecifier)[]; @@ -1351,6 +1374,10 @@ export type StrictTypedTypePolicies = { keyFields?: false | TrackSearchTypeKeySpecifier | (() => undefined | TrackSearchTypeKeySpecifier), fields?: TrackSearchTypeFieldPolicy, }, + TrackStatusType?: Omit & { + keyFields?: false | TrackStatusTypeKeySpecifier | (() => undefined | TrackStatusTypeKeySpecifier), + fields?: TrackStatusTypeFieldPolicy, + }, TrackerEdge?: Omit & { keyFields?: false | TrackerEdgeKeySpecifier | (() => undefined | TrackerEdgeKeySpecifier), fields?: TrackerEdgeFieldPolicy, diff --git a/src/lib/graphql/generated/graphql.ts b/src/lib/graphql/generated/graphql.ts index 233a65ef..c61e582b 100644 --- a/src/lib/graphql/generated/graphql.ts +++ b/src/lib/graphql/generated/graphql.ts @@ -52,7 +52,8 @@ export type BackupRestoreStatus = { export type BindTrackInput = { clientMutationId?: InputMaybe; 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; excludeUnreadChapters?: Maybe; extensionRepos?: Maybe>; + flareSolverrEnabled?: Maybe; + flareSolverrSessionName?: Maybe; + flareSolverrSessionTtl?: Maybe; + flareSolverrTimeout?: Maybe; + flareSolverrUrl?: Maybe; globalUpdateInterval?: Maybe; gqlDebugLogsEnabled?: Maybe; initialOpenInBrowserEnabled?: Maybe; @@ -1406,6 +1412,11 @@ export type PartialSettingsTypeInput = { excludeNotStarted?: InputMaybe; excludeUnreadChapters?: InputMaybe; extensionRepos?: InputMaybe>; + flareSolverrEnabled?: InputMaybe; + flareSolverrSessionName?: InputMaybe; + flareSolverrSessionTtl?: InputMaybe; + flareSolverrTimeout?: InputMaybe; + flareSolverrUrl?: InputMaybe; globalUpdateInterval?: InputMaybe; gqlDebugLogsEnabled?: InputMaybe; initialOpenInBrowserEnabled?: InputMaybe; @@ -1746,6 +1757,11 @@ export type Settings = { excludeNotStarted?: Maybe; excludeUnreadChapters?: Maybe; extensionRepos?: Maybe>; + flareSolverrEnabled?: Maybe; + flareSolverrSessionName?: Maybe; + flareSolverrSessionTtl?: Maybe; + flareSolverrTimeout?: Maybe; + flareSolverrUrl?: Maybe; globalUpdateInterval?: Maybe; gqlDebugLogsEnabled?: Maybe; initialOpenInBrowserEnabled?: Maybe; @@ -1784,6 +1800,11 @@ export type SettingsType = Settings & { excludeNotStarted: Scalars['Boolean']['output']; excludeUnreadChapters: Scalars['Boolean']['output']; extensionRepos: Array; + 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; startDate?: InputMaybe; status?: InputMaybe; - syncId?: InputMaybe; title?: InputMaybe; totalChapters?: InputMaybe; + trackerId?: InputMaybe; }; export type TrackRecordEdge = Edge & { @@ -2008,9 +2029,9 @@ export type TrackRecordFilterInput = { score?: InputMaybe; startDate?: InputMaybe; status?: InputMaybe; - syncId?: InputMaybe; title?: InputMaybe; totalChapters?: InputMaybe; + trackerId?: InputMaybe; }; 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; + statuses: Array; 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, 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, 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; before?: InputMaybe; @@ -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, 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; }>; diff --git a/src/lib/graphql/mutations/MangaMutation.ts b/src/lib/graphql/mutations/MangaMutation.ts index 5d368135..2e6d368a 100644 --- a/src/lib/graphql/mutations/MangaMutation.ts +++ b/src/lib/graphql/mutations/MangaMutation.ts @@ -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) { diff --git a/src/lib/graphql/queries/MangaQuery.ts b/src/lib/graphql/queries/MangaQuery.ts index f4d73e39..8da3af80 100644 --- a/src/lib/graphql/queries/MangaQuery.ts +++ b/src/lib/graphql/queries/MangaQuery.ts @@ -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 + } + } + } + } + } +`; diff --git a/src/lib/graphql/queries/SourceQuery.ts b/src/lib/graphql/queries/SourceQuery.ts index 6d579211..aac5f538 100644 --- a/src/lib/graphql/queries/SourceQuery.ts +++ b/src/lib/graphql/queries/SourceQuery.ts @@ -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 + } + } + } + } +`; diff --git a/src/lib/requests/RequestManager.ts b/src/lib/requests/RequestManager.ts index 2b3f7b18..0f4c76b1 100644 --- a/src/lib/requests/RequestManager.ts +++ b/src/lib/requests/RequestManager.ts @@ -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, + ): AbortabaleApolloQueryResponse { + 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; + } = {}, + ): AbortabaleApolloQueryResponse { + return this.doRequest( + GQLMethod.QUERY, + GET_MANGA_TO_MIGRATE, + { id: Number(mangaId), migrateChapters, migrateCategories }, + options, + ); + } + public getMangaFetch( mangaId: number | string, options?: MutationOptions, @@ -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 { + return this.doRequest( + GQLMethod.MUTATION, + GET_MANGA_TO_MIGRATE_TO_FETCH, + { + id: Number(mangaId), + migrateChapters, + migrateCategories, + }, + options, + ); + } + public useGetMangas( variables: GetMangasQueryVariables, options?: QueryHookOptions, @@ -1360,6 +1428,13 @@ export class RequestManager { return this.doRequest(GQLMethod.QUERY, GET_MANGAS, variables, options); } + public useGetMigratableSourceMangas( + sourceId: string, + options?: QueryHookOptions, + ): AbortableApolloUseQueryResponse { + 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 { return this.doRequest(GQLMethod.USE_QUERY, GET_WEBUI_UPDATE_STATUS, undefined, options); } + + public useGetMigratableSources( + options?: QueryHookOptions, + ): AbortableApolloUseQueryResponse { + return this.doRequest(GQLMethod.USE_QUERY, GET_MIGRATABLE_SOURCES, undefined, options); + } } export const requestManager = new RequestManager(); diff --git a/src/screens/Browse.tsx b/src/screens/Browse.tsx index f1bf5cb5..bb4ced65 100644 --- a/src/screens/Browse.tsx +++ b/src/screens/Browse.tsx @@ -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(0); + useEffect(() => { + setTitle(t('global.label.browse')); + }, [t]); + return ( setTabNum(newTab)}> + @@ -32,6 +40,9 @@ export function Browse() { + + + ); } diff --git a/src/screens/DownloadQueue.tsx b/src/screens/DownloadQueue.tsx index f19dfb0e..597c736b 100644 --- a/src/screens/DownloadQueue.tsx +++ b/src/screens/DownloadQueue.tsx @@ -142,6 +142,11 @@ export const DownloadQueue: React.FC = () => { , ); + + return () => { + setTitle(''); + setAction(null); + }; }, [t, status, isQueueEmpty]); useEffect(() => { diff --git a/src/screens/Extensions.tsx b/src/screens/Extensions.tsx index 5e484b1b..62c4f402 100644 --- a/src/screens/Extensions.tsx +++ b/src/screens/Extensions.tsx @@ -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(null); - const { setTitle, setAction } = useContext(NavBarContext); const [shownLangs, setShownLangs] = useLocalStorage('shownExtensionLangs', extensionDefaultLangs()); const [showNsfw] = useLocalStorage('showNsfw', true); const [query] = useQueryParam('query', StringParam); @@ -169,7 +169,6 @@ export function Extensions() { }; useEffect(() => { - setTitle(t('extension.title')); setAction( <> @@ -182,6 +181,10 @@ export function Extensions() { , ); + + return () => { + setAction(null); + }; }, [t, shownLangs, allLangs]); useEffect(() => { diff --git a/src/screens/Manga.tsx b/src/screens/Manga.tsx index d5093646..bc1f5871 100644 --- a/src/screens/Manga.tsx +++ b/src/screens/Manga.tsx @@ -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 && } , ); + + return () => { + setAction(null); + }; }, [t, error, isValidating, refreshing, manga, refresh]); if (error && !manga) { diff --git a/src/screens/Migrate.tsx b/src/screens/Migrate.tsx new file mode 100644 index 00000000..957ad757 --- /dev/null +++ b/src/screens/Migrate.tsx @@ -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 + >({ + 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(); + + 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 ; + } + + const hasErrorSource = sourceError && isKnownSource === false; + const hasError = hasErrorSource || mangasError; + if (hasError) { + const error = (hasErrorSource ? sourceError : mangasError)!; + return ; + } + + return ( + {}} + isLoading={areMangasLoading} + mangas={(migratableSourceMangasData?.mangas.nodes ?? []) as TPartialManga[]} + gridLayout={gridLayout} + mode="migrate.search" + /> + ); +}; diff --git a/src/screens/Migration.tsx b/src/screens/Migration.tsx new file mode 100644 index 00000000..fedc8334 --- /dev/null +++ b/src/screens/Migration.tsx @@ -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; + +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 ; + } + + if (error) { + return ; + } + + return ( + + {Object.values(migratableSources).map((migratableSource, index) => ( + + + + ))} + + ); +}; diff --git a/src/screens/SearchAll.tsx b/src/screens/SearchAll.tsx index a02bb076..56b9079c 100644 --- a/src/screens/SearchAll.tsx +++ b/src/screens/SearchAll.tsx @@ -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; @@ -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) => { 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( <> @@ -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} /> ))} diff --git a/src/screens/Settings.tsx b/src/screens/Settings.tsx index 6b92da4f..7c619970 100644 --- a/src/screens/Settings.tsx +++ b/src/screens/Settings.tsx @@ -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() { - + diff --git a/src/screens/SourceConfigure.tsx b/src/screens/SourceConfigure.tsx index 3055c4e4..85d2ba04 100644 --- a/src/screens/SourceConfigure.tsx +++ b/src/screens/SourceConfigure.tsx @@ -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 }>(); diff --git a/src/screens/SourceMangas.tsx b/src/screens/SourceMangas.tsx index ab3154a8..599e96b5 100644 --- a/src/screens/SourceMangas.tsx +++ b/src/screens/SourceMangas.tsx @@ -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(() => { diff --git a/src/screens/Sources.tsx b/src/screens/Sources.tsx index ce908a53..5ac22b13 100644 --- a/src/screens/Sources.tsx +++ b/src/screens/Sources.tsx @@ -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('shownSourceLangs', sourceDefualtLangs()); const [showNsfw] = useLocalStorage('showNsfw', true); @@ -84,7 +84,6 @@ export function Sources() { }, []); useEffect(() => { - setTitle(t('source.title')); setAction( <> @@ -100,6 +99,10 @@ export function Sources() { /> , ); + + return () => { + setAction(null); + }; }, [t, shownLangs, sources]); if (isLoading) return ; diff --git a/src/screens/Updates.tsx b/src/screens/Updates.tsx index 2840ea0f..c26d3c85 100644 --- a/src/screens/Updates.tsx +++ b/src/screens/Updates.tsx @@ -104,8 +104,12 @@ export const Updates: React.FC = () => { useEffect(() => { setTitle(t('updates.title')); - setAction(); + + return () => { + setTitle(''); + setAction(null); + }; }, [t, lastUpdateTimestamp]); const downloadForChapter = (chapter: TChapter) => { diff --git a/src/screens/settings/About.tsx b/src/screens/settings/About.tsx index 4e250e08..f660a071 100644 --- a/src/screens/settings/About.tsx +++ b/src/screens/settings/About.tsx @@ -190,6 +190,11 @@ export function About() { useEffect(() => { setTitle(t('settings.about.title')); setAction(null); + + return () => { + setTitle(''); + setAction(null); + }; }, [t]); useSetDefaultBackTo('settings'); diff --git a/src/screens/settings/Backup.tsx b/src/screens/settings/Backup.tsx index 2ff8929d..afe998c2 100644 --- a/src/screens/settings/Backup.tsx +++ b/src/screens/settings/Backup.tsx @@ -62,6 +62,11 @@ export function Backup() { useEffect(() => { setTitle(t('settings.backup.title')); setAction(null); + + return () => { + setTitle(''); + setAction(null); + }; }, [t]); useSetDefaultBackTo('settings'); diff --git a/src/screens/settings/Categories.tsx b/src/screens/settings/Categories.tsx index 2c7b332b..6a9fd009 100644 --- a/src/screens/settings/Categories.tsx +++ b/src/screens/settings/Categories.tsx @@ -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(); diff --git a/src/screens/settings/DefaultReaderSettings.tsx b/src/screens/settings/DefaultReaderSettings.tsx index 5be2b240..8992a9fd 100644 --- a/src/screens/settings/DefaultReaderSettings.tsx +++ b/src/screens/settings/DefaultReaderSettings.tsx @@ -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(); diff --git a/src/screens/settings/DownloadSettings.tsx b/src/screens/settings/DownloadSettings.tsx index e1719f90..a5d085f2 100644 --- a/src/screens/settings/DownloadSettings.tsx +++ b/src/screens/settings/DownloadSettings.tsx @@ -48,6 +48,11 @@ export const DownloadSettings = () => { useEffect(() => { setTitle(t('download.settings.title')); setAction(null); + + return () => { + setTitle(''); + setAction(null); + }; }, [t]); const { data } = requestManager.useGetServerSettings(); diff --git a/src/screens/settings/LibrarySettings.tsx b/src/screens/settings/LibrarySettings.tsx index 70682a90..351f710c 100644 --- a/src/screens/settings/LibrarySettings.tsx +++ b/src/screens/settings/LibrarySettings.tsx @@ -46,6 +46,11 @@ export function LibrarySettings() { useEffect(() => { setTitle(t('library.settings.title')); setAction(null); + + return () => { + setTitle(''); + setAction(null); + }; }, [t]); useSetDefaultBackTo('settings'); diff --git a/src/screens/settings/ServerSettings.tsx b/src/screens/settings/ServerSettings.tsx index df92b527..ad3fd895 100644 --- a/src/screens/settings/ServerSettings.tsx +++ b/src/screens/settings/ServerSettings.tsx @@ -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(); diff --git a/src/screens/settings/WebUISettings.tsx b/src/screens/settings/WebUISettings.tsx index fd05d836..9c3e27e8 100644 --- a/src/screens/settings/WebUISettings.tsx +++ b/src/screens/settings/WebUISettings.tsx @@ -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();