Feature/manga migration (#536)

* Add "migration" tab to "Browse" screen

* Add missing useEffect cleanup for navbar title and actions

* Make "SourceGridLayout" reusable

* Add migration tab to "Browse"

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

View File

@@ -37,6 +37,7 @@ import { ServerUpdateChecker } from '@/components/settings/ServerUpdateChecker.t
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { ExtensionSettings } from '@/screens/settings/ExtensionSettings.tsx';
import { WebUISettings } from '@/screens/settings/WebUISettings.tsx';
import { Migrate } from '@/screens/Migrate.tsx';
if (process.env.NODE_ENV !== 'production') {
// Adds messages only in a dev environment
@@ -120,6 +121,10 @@ export const App: React.FC = () => (
<Route path="updates" element={<Updates />} />
<Route path="extensions" element={<Extensions />} />
<Route path="browse" element={<Browse />} />
<Route path="migrate/source/:sourceId">
<Route index element={<Migrate />} />
<Route path="manga/:mangaId/search" element={<SearchAll />} />
</Route>
</Routes>
</Container>
<Routes>

View File

@@ -13,6 +13,7 @@ import { Link } from 'react-router-dom';
import { Avatar, Box, CardContent, Stack, styled, Tooltip } from '@mui/material';
import { useTranslation } from 'react-i18next';
import PopupState, { bindMenu } from 'material-ui-popup-state';
import { useState } from 'react';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import { SpinnerImage } from '@/components/util/SpinnerImage';
@@ -22,6 +23,7 @@ import { SelectableCollectionReturnType } from '@/components/collection/useSelec
import { MangaOptionButton } from '@/components/manga/MangaOptionButton.tsx';
import { MangaActionMenuItems, SingleModeProps } from '@/components/manga/MangaActionMenuItems.tsx';
import { Menu } from '@/components/menu/Menu.tsx';
import { MigrateDialog } from '@/components/MigrateDialog.tsx';
const BottomGradient = styled('div')({
position: 'absolute',
@@ -66,18 +68,34 @@ const BadgeContainer = styled('div')({
},
});
interface IProps {
type MangaCardMode = 'default' | 'migrate.search' | 'migrate.select';
export interface MangaCardProps {
manga: TPartialManga;
gridLayout?: GridLayout;
inLibraryIndicator?: boolean;
selected?: boolean | null;
handleSelection?: SelectableCollectionReturnType<TManga['id']>['handleSelection'];
mode?: MangaCardMode;
}
export const MangaCard = (props: IProps) => {
const getMangaLinkTo = (mode: MangaCardMode, mangaId: number, sourceId: string, mangaTitle: string): string => {
switch (mode) {
case 'default':
return `/manga/${mangaId}/`;
case 'migrate.search':
return `/migrate/source/${sourceId}/manga/${mangaId}/search?query=${mangaTitle}`;
case 'migrate.select':
return '';
default:
throw new Error(`getMangaLinkTo: unexpected MangaCardMode "${mode}"`);
}
};
export const MangaCard = (props: MangaCardProps) => {
const { t } = useTranslation();
const { manga, gridLayout, inLibraryIndicator, selected, handleSelection } = props;
const { manga, gridLayout, inLibraryIndicator, selected, handleSelection, mode = 'default' } = props;
const {
id,
title,
@@ -93,23 +111,39 @@ export const MangaCard = (props: IProps) => {
options: { showContinueReadingButton, showUnreadBadge, showDownloadBadge },
} = useLibraryOptionsContext();
const mangaLinkTo = `/manga/${id}/`;
const mangaLinkTo = getMangaLinkTo(mode, manga.id, manga.source?.id, manga.title);
const nextChapterIndexToRead = (latestReadChapter?.sourceOrder ?? 0) + 1;
const isLatestChapterRead = chapters?.totalCount === latestReadChapter?.sourceOrder;
const [isMigrateDialogOpen, setIsMigrateDialogOpen] = useState(false);
if (gridLayout !== GridLayout.List) {
return (
<>
{isMigrateDialogOpen && (
<MigrateDialog mangaIdToMigrateTo={manga.id} onClose={() => setIsMigrateDialogOpen(false)} />
)}
<PopupState variant="popover" popupId="manga-card-action-menu">
{(popupState) => (
<>
<Link
onClick={(e) => {
if (selected === null) {
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}
@@ -158,7 +192,9 @@ export const MangaCard = (props: IProps) => {
>
<BadgeContainer>
{inLibraryIndicator && inLibrary && (
<Typography sx={{ backgroundColor: 'primary.dark', zIndex: '1' }}>
<Typography
sx={{ backgroundColor: 'primary.dark', zIndex: '1' }}
>
{t('manga.button.in_library')}
</Typography>
)}
@@ -284,10 +320,15 @@ export const MangaCard = (props: IProps) => {
</>
)}
</PopupState>
</>
);
}
return (
<>
{isMigrateDialogOpen && (
<MigrateDialog mangaIdToMigrateTo={manga.id} onClose={() => setIsMigrateDialogOpen(false)} />
)}
<PopupState variant="popover" popupId="manga-card-action-menu">
{(popupState) => (
<>
@@ -355,7 +396,9 @@ export const MangaCard = (props: IProps) => {
</Typography>
)}
{showUnreadBadge && unread! > 0 && (
<Typography sx={{ backgroundColor: 'primary.dark' }}>{unread}</Typography>
<Typography sx={{ backgroundColor: 'primary.dark' }}>
{unread}
</Typography>
)}
{showDownloadBadge && downloadCount! > 0 && (
<Typography
@@ -399,5 +442,6 @@ export const MangaCard = (props: IProps) => {
</>
)}
</PopupState>
</>
);
};

View File

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

View File

@@ -0,0 +1,97 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import { useTranslation } from 'react-i18next';
import { Stack } from '@mui/material';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useState } from 'react';
import FormGroup from '@mui/material/FormGroup';
import { CheckboxInput } from '@/components/atoms/CheckboxInput.tsx';
import { Mangas, MigrateMode } from '@/lib/data/Mangas.ts';
import { makeToast } from '@/components/util/Toast.tsx';
export const MigrateDialog = ({ mangaIdToMigrateTo, onClose }: { mangaIdToMigrateTo: number; onClose: () => void }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { mangaId: mangaIdAsString } = useParams<{ mangaId: string }>();
const mangaId = Number(mangaIdAsString);
const [includeChapters, setIncludeChapters] = useState(true);
const [includeCategories, setIncludeCategories] = useState(true);
const [isMigrationInProcess, setIsMigrationInProcess] = useState(false);
const migrate = async (mode: MigrateMode) => {
if (mangaId == null) {
throw new Error(`MigrateDialog::migrate: unexpected mangaId "${mangaId}"`);
}
makeToast(t('migrate.label.info'), 'info');
setIsMigrationInProcess(true);
try {
await Mangas.migrate(mangaId, mangaIdToMigrateTo, {
mode,
migrateChapters: includeChapters,
migrateCategories: includeCategories,
});
navigate(`/manga/${mangaIdToMigrateTo}`);
} catch (e) {
setIsMigrationInProcess(false);
}
};
return (
<Dialog open fullWidth onClose={onClose}>
<DialogTitle>{t('migrate.dialog.title')}</DialogTitle>
<DialogContent dividers>
<FormGroup>
<CheckboxInput
disabled={isMigrationInProcess}
label={t('chapter.title')}
checked={includeChapters}
onChange={(_, checked) => setIncludeChapters(checked)}
/>
<CheckboxInput
disabled={isMigrationInProcess}
label={t('category.title.category_one')}
checked={includeCategories}
onChange={(_, checked) => setIncludeCategories(checked)}
/>
</FormGroup>
</DialogContent>
<DialogActions>
<Stack sx={{ width: '100%' }} direction="row" justifyContent="space-between">
<Button disabled={isMigrationInProcess} component={Link} to={`/manga/${mangaIdToMigrateTo}`}>
{t('migrate.dialog.action.button.show_entry')}
</Button>
<Stack direction="row">
<Button disabled={isMigrationInProcess} onClick={onClose}>
{t('global.button.cancel')}
</Button>
<Button disabled={isMigrationInProcess} onClick={() => migrate('copy')}>
{t('global.button.copy')}
</Button>
<Button disabled={isMigrationInProcess} onClick={() => migrate('migrate')}>
{t('global.button.migrate')}
</Button>
</Stack>
</Stack>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,60 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import { Box, CardActionArea, Chip } from '@mui/material';
import Avatar from '@mui/material/Avatar';
import Typography from '@mui/material/Typography';
import { Link } from 'react-router-dom';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { GetMigratableSourcesQuery } from '@/lib/graphql/generated/graphql.ts';
import { translateExtensionLanguage } from '@/screens/util/Extensions.ts';
export type TMigratableSource = NonNullable<GetMigratableSourcesQuery['mangas']['nodes'][number]['source']> & {
mangaCount: number;
};
// TODO - cleanup source/extension components
export const MigrationCard = ({ id, name, lang, iconUrl, mangaCount }: TMigratableSource) => (
<Card>
<CardActionArea component={Link} to={`/migrate/source/${id}/`}>
<CardContent
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: 2,
}}
>
<Box sx={{ display: 'flex' }}>
<Avatar
variant="rounded"
sx={{
width: 56,
height: 56,
flex: '0 0 auto',
mr: 2,
}}
alt={name}
src={requestManager.getValidImgUrlFor(iconUrl)}
/>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="h5" component="h2">
{name}
</Typography>
<Typography variant="caption" display="block">
{translateExtensionLanguage(lang)}
</Typography>
</Box>
</Box>
<Chip sx={{ borderRadius: '5px' }} size="small" label={mangaCount} />
</CardContent>
</CardActionArea>
</Card>
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import { GridLayouts } from '@/components/source/GridLayouts.tsx';
export function SourceGridLayout() {
const {
options: { SourcegridLayout },
setOptions,
} = useLibraryOptionsContext();
function setGridContextOptions(gridLayout: GridLayout) {
setOptions((prev: any) => ({ ...prev, SourcegridLayout: gridLayout }));
}
return <GridLayouts gridLayout={SourcegridLayout} onChange={setGridContextOptions} />;
}

View File

@@ -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": {

View File

@@ -78,6 +78,7 @@ export type ChapterIdInfo = Pick<TChapter, 'id'>;
export type ChapterDownloadInfo = ChapterIdInfo & Pick<TChapter, 'isDownloaded'>;
export type ChapterBookmarkInfo = ChapterIdInfo & Pick<TChapter, 'isBookmarked'>;
export type ChapterReadInfo = ChapterIdInfo & Pick<TChapter, 'isRead'>;
export type ChapterNumberInfo = ChapterIdInfo & Pick<TChapter, 'chapterNumber'>;
export class Chapters {
static getIds(chapters: { id: number }[]): number[] {
@@ -130,6 +131,23 @@ export class Chapters {
return chapters.filter((chapter) => !Chapters.isRead(chapter));
}
static getMatchingChapterNumberChapters<Chapter extends ChapterNumberInfo>(
chaptersA: Chapter[],
chaptersB: Chapter[],
): [ChapterA: Chapter, ChapterB: Chapter][] {
return chaptersA
.map((chapterA) => {
const matchingChapter = chaptersB.find((chapterB) => chapterA.chapterNumber === chapterB.chapterNumber);
if (!matchingChapter) {
return null;
}
return [chapterA, matchingChapter];
})
.filter((matchingChapters) => matchingChapters !== null) as [Chapter, Chapter][];
}
static async download(chapterIds: number[]): Promise<void> {
return Chapters.executeAction(
'download',

View File

@@ -12,6 +12,8 @@ import { requestManager } from '@/lib/requests/RequestManager.ts';
import {
ChapterConditionInput,
GetMangasChapterIdsWithStateQuery,
GetMangaToMigrateQuery,
GetMangaToMigrateToFetchMutation,
UpdateMangaCategoriesPatchInput,
} from '@/lib/graphql/generated/graphql.ts';
import { Chapters } from '@/lib/data/Chapters.ts';
@@ -23,7 +25,8 @@ export type MangaAction =
| 'mark_as_read'
| 'mark_as_unread'
| 'remove_from_library'
| 'change_categories';
| 'change_categories'
| 'migrate';
export const actionToTranslationKey: {
[key in MangaAction]: {
@@ -83,11 +86,38 @@ export const actionToTranslationKey: {
success: 'manga.action.category.label.success',
error: 'manga.action.category.label.error',
},
migrate: {
action: {
single: 'global.button.migrate',
selected: 'global.button.migrate', // not supported
},
success: 'manga.action.migrate.label.success',
error: 'manga.action.migrate.label.error',
},
};
export type MangaChapterCountInfo = { chapters: Pick<TManga['chapters'], 'totalCount'> };
export type MangaDownloadInfo = Pick<TManga, 'downloadCount'> & MangaChapterCountInfo;
export type MangaUnreadInfo = Pick<TManga, 'unreadCount'> & MangaChapterCountInfo;
export type MigrateMode = 'copy' | 'migrate';
type MarkAsReadOptions = { wasManuallyMarkedAsRead: boolean };
type ChangeCategoriesOptions = { changeCategoriesPatch: UpdateMangaCategoriesPatchInput };
type MigrateOptions = {
mangaIdToMigrateTo: number;
mode: MigrateMode;
migrateChapters?: boolean;
migrateCategories?: boolean;
};
type PerformActionOptions<Action extends MangaAction> = Action extends 'mark_as_read'
? MarkAsReadOptions & PropertiesNever<ChangeCategoriesOptions> & PropertiesNever<MigrateOptions>
: Action extends 'change_categories'
? PropertiesNever<MarkAsReadOptions> & ChangeCategoriesOptions & PropertiesNever<MigrateOptions>
: Action extends 'migrate'
? PropertiesNever<MarkAsReadOptions> & PropertiesNever<ChangeCategoriesOptions> & MigrateOptions
: Partial<MarkAsReadOptions> & Partial<ChangeCategoriesOptions> & Partial<MigrateOptions>;
export class Mangas {
static getIds(mangas: { id: number }[]): number[] {
return mangas.map((manga) => manga.id);
@@ -185,6 +215,89 @@ export class Mangas {
);
}
private static async migrateChapters(
mangaToMigrate: GetMangaToMigrateQuery['manga'],
mangaToMigrateToInfo: GetMangaToMigrateToFetchMutation,
): Promise<void> {
if (!mangaToMigrate.chapters || !mangaToMigrateToInfo.fetchChapters?.chapters) {
throw new Error('Chapters are missing');
}
const chaptersToMigrate = mangaToMigrate.chapters.nodes;
const chaptersToMigrateTo = mangaToMigrateToInfo.fetchChapters?.chapters;
const migratableChapters = Chapters.getMatchingChapterNumberChapters(chaptersToMigrate, chaptersToMigrateTo);
const readChapters: number[] = [];
const bookmarkedChapters: number[] = [];
migratableChapters.forEach(([chapterToMigrate, chapterToMigrateTo]) => {
const { isRead, isBookmarked } = chapterToMigrate;
if (isRead) {
readChapters.push(chapterToMigrateTo.id);
}
if (isBookmarked) {
bookmarkedChapters.push(chapterToMigrateTo.id);
}
});
await Promise.all([
requestManager.updateChapters(readChapters, { isRead: true }).response,
requestManager.updateChapters(bookmarkedChapters, { isBookmarked: true }).response,
]);
}
private static async migrateCategories(
mangaToMigrate: GetMangaToMigrateQuery['manga'],
mangaToMigrateTo: GetMangaToMigrateToFetchMutation['fetchManga']['manga'],
): Promise<void> {
if (!mangaToMigrate?.categories) {
throw new Error('Categories are missing');
}
requestManager.updateMangasCategories([mangaToMigrateTo.id], {
addToCategories: mangaToMigrate.categories.nodes.map((category) => category.id),
});
}
static async migrate(
mangaId: number,
mangaIdToMigrateTo: number,
{ mode, migrateChapters, migrateCategories }: Omit<MigrateOptions, 'mangaIdToMigrateTo'>,
): Promise<void> {
return Mangas.executeAction('migrate', 1, async () => {
const [{ data: mangaToMigrateData }, { data: mangaToMigrateToData }] = await Promise.all([
requestManager.getMangaToMigrate(mangaId, { migrateChapters, migrateCategories }).response,
requestManager.getMangaToMigrateToFetch(mangaIdToMigrateTo, { migrateChapters, migrateCategories })
.response,
]);
if (!mangaToMigrateData.manga || !mangaToMigrateToData?.fetchManga.manga) {
throw new Error('Mangas::migrate: missing manga data');
}
if (
migrateChapters &&
(!mangaToMigrateData.manga.chapters || !mangaToMigrateToData.fetchChapters?.chapters)
) {
throw new Error('Mangas::migrate: missing chapters data');
}
await Promise.all([
migrateChapters ? Mangas.migrateChapters(mangaToMigrateData.manga, mangaToMigrateToData) : undefined,
migrateCategories
? Mangas.migrateCategories(mangaToMigrateData.manga, mangaToMigrateToData.fetchManga.manga)
: undefined,
!mangaToMigrateToData.fetchManga.manga.inLibrary
? requestManager.updateManga(mangaIdToMigrateTo, { inLibrary: true }).response
: undefined,
mode === 'migrate' ? requestManager.updateManga(mangaId, { inLibrary: false }).response : undefined,
]);
});
}
private static async executeAction(
action: MangaAction,
itemCount: number,
@@ -205,11 +318,9 @@ export class Mangas {
{
wasManuallyMarkedAsRead,
changeCategoriesPatch,
}: Action extends 'mark_as_read'
? { wasManuallyMarkedAsRead: boolean; changeCategoriesPatch?: never }
: Action extends 'change_categories'
? { wasManuallyMarkedAsRead?: never; changeCategoriesPatch: UpdateMangaCategoriesPatchInput }
: { wasManuallyMarkedAsRead?: boolean; changeCategoriesPatch?: UpdateMangaCategoriesPatchInput },
mangaIdToMigrateTo,
...migrateOptions
}: PerformActionOptions<Action>,
): Promise<void> {
switch (action) {
case 'download':
@@ -224,6 +335,9 @@ export class Mangas {
return Mangas.removeFromLibrary(mangaIds);
case 'change_categories':
return Mangas.changeCategories(mangaIds, changeCategoriesPatch!);
case 'migrate': {
return Mangas.migrate(mangaIds[0], mangaIdToMigrateTo!, migrateOptions as unknown as MigrateOptions);
}
default:
throw new Error(`Mangas::performAction: unknown action "${action}"`);
}

View File

@@ -508,7 +508,7 @@ export type PageInfoFieldPolicy = {
hasPreviousPage?: FieldPolicy<any> | FieldReadFunction<any>,
startCursor?: FieldPolicy<any> | FieldReadFunction<any>
};
export type PartialSettingsTypeKeySpecifier = ('autoDownloadAheadLimit' | 'autoDownloadNewChapters' | 'backupInterval' | 'backupPath' | 'backupTTL' | 'backupTime' | 'basicAuthEnabled' | 'basicAuthPassword' | 'basicAuthUsername' | 'debugLogsEnabled' | 'downloadAsCbz' | 'downloadsPath' | 'electronPath' | 'excludeCompleted' | 'excludeEntryWithUnreadChapters' | 'excludeNotStarted' | 'excludeUnreadChapters' | 'extensionRepos' | 'globalUpdateInterval' | 'gqlDebugLogsEnabled' | 'initialOpenInBrowserEnabled' | 'ip' | 'localSourcePath' | 'maxSourcesInParallel' | 'port' | 'socksProxyEnabled' | 'socksProxyHost' | 'socksProxyPort' | 'systemTrayEnabled' | 'updateMangas' | 'webUIChannel' | 'webUIFlavor' | 'webUIInterface' | 'webUIUpdateCheckInterval' | PartialSettingsTypeKeySpecifier)[];
export type PartialSettingsTypeKeySpecifier = ('autoDownloadAheadLimit' | 'autoDownloadNewChapters' | 'backupInterval' | 'backupPath' | 'backupTTL' | 'backupTime' | 'basicAuthEnabled' | 'basicAuthPassword' | 'basicAuthUsername' | 'debugLogsEnabled' | 'downloadAsCbz' | 'downloadsPath' | 'electronPath' | 'excludeCompleted' | 'excludeEntryWithUnreadChapters' | 'excludeNotStarted' | 'excludeUnreadChapters' | 'extensionRepos' | 'flareSolverrEnabled' | 'flareSolverrSessionName' | 'flareSolverrSessionTtl' | 'flareSolverrTimeout' | 'flareSolverrUrl' | 'globalUpdateInterval' | 'gqlDebugLogsEnabled' | 'initialOpenInBrowserEnabled' | 'ip' | 'localSourcePath' | 'maxSourcesInParallel' | 'port' | 'socksProxyEnabled' | 'socksProxyHost' | 'socksProxyPort' | 'systemTrayEnabled' | 'updateMangas' | 'webUIChannel' | 'webUIFlavor' | 'webUIInterface' | 'webUIUpdateCheckInterval' | PartialSettingsTypeKeySpecifier)[];
export type PartialSettingsTypeFieldPolicy = {
autoDownloadAheadLimit?: FieldPolicy<any> | FieldReadFunction<any>,
autoDownloadNewChapters?: FieldPolicy<any> | FieldReadFunction<any>,
@@ -528,6 +528,11 @@ export type PartialSettingsTypeFieldPolicy = {
excludeNotStarted?: FieldPolicy<any> | FieldReadFunction<any>,
excludeUnreadChapters?: FieldPolicy<any> | FieldReadFunction<any>,
extensionRepos?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrSessionName?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrSessionTtl?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrTimeout?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrUrl?: FieldPolicy<any> | FieldReadFunction<any>,
globalUpdateInterval?: FieldPolicy<any> | FieldReadFunction<any>,
gqlDebugLogsEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
initialOpenInBrowserEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
@@ -631,7 +636,7 @@ export type SetSettingsPayloadFieldPolicy = {
clientMutationId?: FieldPolicy<any> | FieldReadFunction<any>,
settings?: FieldPolicy<any> | FieldReadFunction<any>
};
export type SettingsKeySpecifier = ('autoDownloadAheadLimit' | 'autoDownloadNewChapters' | 'backupInterval' | 'backupPath' | 'backupTTL' | 'backupTime' | 'basicAuthEnabled' | 'basicAuthPassword' | 'basicAuthUsername' | 'debugLogsEnabled' | 'downloadAsCbz' | 'downloadsPath' | 'electronPath' | 'excludeCompleted' | 'excludeEntryWithUnreadChapters' | 'excludeNotStarted' | 'excludeUnreadChapters' | 'extensionRepos' | 'globalUpdateInterval' | 'gqlDebugLogsEnabled' | 'initialOpenInBrowserEnabled' | 'ip' | 'localSourcePath' | 'maxSourcesInParallel' | 'port' | 'socksProxyEnabled' | 'socksProxyHost' | 'socksProxyPort' | 'systemTrayEnabled' | 'updateMangas' | 'webUIChannel' | 'webUIFlavor' | 'webUIInterface' | 'webUIUpdateCheckInterval' | SettingsKeySpecifier)[];
export type SettingsKeySpecifier = ('autoDownloadAheadLimit' | 'autoDownloadNewChapters' | 'backupInterval' | 'backupPath' | 'backupTTL' | 'backupTime' | 'basicAuthEnabled' | 'basicAuthPassword' | 'basicAuthUsername' | 'debugLogsEnabled' | 'downloadAsCbz' | 'downloadsPath' | 'electronPath' | 'excludeCompleted' | 'excludeEntryWithUnreadChapters' | 'excludeNotStarted' | 'excludeUnreadChapters' | 'extensionRepos' | 'flareSolverrEnabled' | 'flareSolverrSessionName' | 'flareSolverrSessionTtl' | 'flareSolverrTimeout' | 'flareSolverrUrl' | 'globalUpdateInterval' | 'gqlDebugLogsEnabled' | 'initialOpenInBrowserEnabled' | 'ip' | 'localSourcePath' | 'maxSourcesInParallel' | 'port' | 'socksProxyEnabled' | 'socksProxyHost' | 'socksProxyPort' | 'systemTrayEnabled' | 'updateMangas' | 'webUIChannel' | 'webUIFlavor' | 'webUIInterface' | 'webUIUpdateCheckInterval' | SettingsKeySpecifier)[];
export type SettingsFieldPolicy = {
autoDownloadAheadLimit?: FieldPolicy<any> | FieldReadFunction<any>,
autoDownloadNewChapters?: FieldPolicy<any> | FieldReadFunction<any>,
@@ -651,6 +656,11 @@ export type SettingsFieldPolicy = {
excludeNotStarted?: FieldPolicy<any> | FieldReadFunction<any>,
excludeUnreadChapters?: FieldPolicy<any> | FieldReadFunction<any>,
extensionRepos?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrSessionName?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrSessionTtl?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrTimeout?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrUrl?: FieldPolicy<any> | FieldReadFunction<any>,
globalUpdateInterval?: FieldPolicy<any> | FieldReadFunction<any>,
gqlDebugLogsEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
initialOpenInBrowserEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
@@ -668,7 +678,7 @@ export type SettingsFieldPolicy = {
webUIInterface?: FieldPolicy<any> | FieldReadFunction<any>,
webUIUpdateCheckInterval?: FieldPolicy<any> | FieldReadFunction<any>
};
export type SettingsTypeKeySpecifier = ('autoDownloadAheadLimit' | 'autoDownloadNewChapters' | 'backupInterval' | 'backupPath' | 'backupTTL' | 'backupTime' | 'basicAuthEnabled' | 'basicAuthPassword' | 'basicAuthUsername' | 'debugLogsEnabled' | 'downloadAsCbz' | 'downloadsPath' | 'electronPath' | 'excludeCompleted' | 'excludeEntryWithUnreadChapters' | 'excludeNotStarted' | 'excludeUnreadChapters' | 'extensionRepos' | 'globalUpdateInterval' | 'gqlDebugLogsEnabled' | 'initialOpenInBrowserEnabled' | 'ip' | 'localSourcePath' | 'maxSourcesInParallel' | 'port' | 'socksProxyEnabled' | 'socksProxyHost' | 'socksProxyPort' | 'systemTrayEnabled' | 'updateMangas' | 'webUIChannel' | 'webUIFlavor' | 'webUIInterface' | 'webUIUpdateCheckInterval' | SettingsTypeKeySpecifier)[];
export type SettingsTypeKeySpecifier = ('autoDownloadAheadLimit' | 'autoDownloadNewChapters' | 'backupInterval' | 'backupPath' | 'backupTTL' | 'backupTime' | 'basicAuthEnabled' | 'basicAuthPassword' | 'basicAuthUsername' | 'debugLogsEnabled' | 'downloadAsCbz' | 'downloadsPath' | 'electronPath' | 'excludeCompleted' | 'excludeEntryWithUnreadChapters' | 'excludeNotStarted' | 'excludeUnreadChapters' | 'extensionRepos' | 'flareSolverrEnabled' | 'flareSolverrSessionName' | 'flareSolverrSessionTtl' | 'flareSolverrTimeout' | 'flareSolverrUrl' | 'globalUpdateInterval' | 'gqlDebugLogsEnabled' | 'initialOpenInBrowserEnabled' | 'ip' | 'localSourcePath' | 'maxSourcesInParallel' | 'port' | 'socksProxyEnabled' | 'socksProxyHost' | 'socksProxyPort' | 'systemTrayEnabled' | 'updateMangas' | 'webUIChannel' | 'webUIFlavor' | 'webUIInterface' | 'webUIUpdateCheckInterval' | SettingsTypeKeySpecifier)[];
export type SettingsTypeFieldPolicy = {
autoDownloadAheadLimit?: FieldPolicy<any> | FieldReadFunction<any>,
autoDownloadNewChapters?: FieldPolicy<any> | FieldReadFunction<any>,
@@ -688,6 +698,11 @@ export type SettingsTypeFieldPolicy = {
excludeNotStarted?: FieldPolicy<any> | FieldReadFunction<any>,
excludeUnreadChapters?: FieldPolicy<any> | FieldReadFunction<any>,
extensionRepos?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrSessionName?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrSessionTtl?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrTimeout?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrUrl?: FieldPolicy<any> | FieldReadFunction<any>,
globalUpdateInterval?: FieldPolicy<any> | FieldReadFunction<any>,
gqlDebugLogsEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
initialOpenInBrowserEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
@@ -785,7 +800,7 @@ export type TrackRecordNodeListFieldPolicy = {
pageInfo?: FieldPolicy<any> | FieldReadFunction<any>,
totalCount?: FieldPolicy<any> | FieldReadFunction<any>
};
export type TrackRecordTypeKeySpecifier = ('displayScore' | 'finishDate' | 'id' | 'lastChapterRead' | 'libraryId' | 'manga' | 'mangaId' | 'remoteId' | 'remoteUrl' | 'score' | 'startDate' | 'status' | 'syncId' | 'title' | 'totalChapters' | 'tracker' | TrackRecordTypeKeySpecifier)[];
export type TrackRecordTypeKeySpecifier = ('displayScore' | 'finishDate' | 'id' | 'lastChapterRead' | 'libraryId' | 'manga' | 'mangaId' | 'remoteId' | 'remoteUrl' | 'score' | 'startDate' | 'status' | 'title' | 'totalChapters' | 'tracker' | 'trackerId' | TrackRecordTypeKeySpecifier)[];
export type TrackRecordTypeFieldPolicy = {
displayScore?: FieldPolicy<any> | FieldReadFunction<any>,
finishDate?: FieldPolicy<any> | FieldReadFunction<any>,
@@ -799,25 +814,31 @@ export type TrackRecordTypeFieldPolicy = {
score?: FieldPolicy<any> | FieldReadFunction<any>,
startDate?: FieldPolicy<any> | FieldReadFunction<any>,
status?: FieldPolicy<any> | FieldReadFunction<any>,
syncId?: FieldPolicy<any> | FieldReadFunction<any>,
title?: FieldPolicy<any> | FieldReadFunction<any>,
totalChapters?: FieldPolicy<any> | FieldReadFunction<any>,
tracker?: FieldPolicy<any> | FieldReadFunction<any>
};
export type TrackSearchTypeKeySpecifier = ('coverUrl' | 'mediaId' | 'publishingStatus' | 'publishingType' | 'startDate' | 'summary' | 'syncId' | 'title' | 'totalChapters' | 'tracker' | 'trackingUrl' | TrackSearchTypeKeySpecifier)[];
export type TrackSearchTypeFieldPolicy = {
coverUrl?: FieldPolicy<any> | FieldReadFunction<any>,
mediaId?: FieldPolicy<any> | FieldReadFunction<any>,
publishingStatus?: FieldPolicy<any> | FieldReadFunction<any>,
publishingType?: FieldPolicy<any> | FieldReadFunction<any>,
startDate?: FieldPolicy<any> | FieldReadFunction<any>,
summary?: FieldPolicy<any> | FieldReadFunction<any>,
syncId?: FieldPolicy<any> | FieldReadFunction<any>,
title?: FieldPolicy<any> | FieldReadFunction<any>,
totalChapters?: FieldPolicy<any> | FieldReadFunction<any>,
tracker?: FieldPolicy<any> | FieldReadFunction<any>,
trackerId?: FieldPolicy<any> | FieldReadFunction<any>
};
export type TrackSearchTypeKeySpecifier = ('coverUrl' | 'id' | 'publishingStatus' | 'publishingType' | 'remoteId' | 'startDate' | 'summary' | 'title' | 'totalChapters' | 'tracker' | 'trackerId' | 'trackingUrl' | TrackSearchTypeKeySpecifier)[];
export type TrackSearchTypeFieldPolicy = {
coverUrl?: FieldPolicy<any> | FieldReadFunction<any>,
id?: FieldPolicy<any> | FieldReadFunction<any>,
publishingStatus?: FieldPolicy<any> | FieldReadFunction<any>,
publishingType?: FieldPolicy<any> | FieldReadFunction<any>,
remoteId?: FieldPolicy<any> | FieldReadFunction<any>,
startDate?: FieldPolicy<any> | FieldReadFunction<any>,
summary?: FieldPolicy<any> | FieldReadFunction<any>,
title?: FieldPolicy<any> | FieldReadFunction<any>,
totalChapters?: FieldPolicy<any> | FieldReadFunction<any>,
tracker?: FieldPolicy<any> | FieldReadFunction<any>,
trackerId?: FieldPolicy<any> | FieldReadFunction<any>,
trackingUrl?: FieldPolicy<any> | FieldReadFunction<any>
};
export type TrackStatusTypeKeySpecifier = ('name' | 'value' | TrackStatusTypeKeySpecifier)[];
export type TrackStatusTypeFieldPolicy = {
name?: FieldPolicy<any> | FieldReadFunction<any>,
value?: FieldPolicy<any> | FieldReadFunction<any>
};
export type TrackerEdgeKeySpecifier = ('cursor' | 'node' | TrackerEdgeKeySpecifier)[];
export type TrackerEdgeFieldPolicy = {
cursor?: FieldPolicy<any> | FieldReadFunction<any>,
@@ -830,13 +851,15 @@ export type TrackerNodeListFieldPolicy = {
pageInfo?: FieldPolicy<any> | FieldReadFunction<any>,
totalCount?: FieldPolicy<any> | FieldReadFunction<any>
};
export type TrackerTypeKeySpecifier = ('authUrl' | 'icon' | 'id' | 'isLoggedIn' | 'name' | 'trackRecords' | TrackerTypeKeySpecifier)[];
export type TrackerTypeKeySpecifier = ('authUrl' | 'icon' | 'id' | 'isLoggedIn' | 'name' | 'scores' | 'statuses' | 'trackRecords' | TrackerTypeKeySpecifier)[];
export type TrackerTypeFieldPolicy = {
authUrl?: FieldPolicy<any> | FieldReadFunction<any>,
icon?: FieldPolicy<any> | FieldReadFunction<any>,
id?: FieldPolicy<any> | FieldReadFunction<any>,
isLoggedIn?: FieldPolicy<any> | FieldReadFunction<any>,
name?: FieldPolicy<any> | FieldReadFunction<any>,
scores?: FieldPolicy<any> | FieldReadFunction<any>,
statuses?: FieldPolicy<any> | FieldReadFunction<any>,
trackRecords?: FieldPolicy<any> | FieldReadFunction<any>
};
export type TriStateFilterKeySpecifier = ('default' | 'name' | TriStateFilterKeySpecifier)[];
@@ -1351,6 +1374,10 @@ export type StrictTypedTypePolicies = {
keyFields?: false | TrackSearchTypeKeySpecifier | (() => undefined | TrackSearchTypeKeySpecifier),
fields?: TrackSearchTypeFieldPolicy,
},
TrackStatusType?: Omit<TypePolicy, "fields" | "keyFields"> & {
keyFields?: false | TrackStatusTypeKeySpecifier | (() => undefined | TrackStatusTypeKeySpecifier),
fields?: TrackStatusTypeFieldPolicy,
},
TrackerEdge?: Omit<TypePolicy, "fields" | "keyFields"> & {
keyFields?: false | TrackerEdgeKeySpecifier | (() => undefined | TrackerEdgeKeySpecifier),
fields?: TrackerEdgeFieldPolicy,

View File

@@ -52,7 +52,8 @@ export type BackupRestoreStatus = {
export type BindTrackInput = {
clientMutationId?: InputMaybe<Scalars['String']['input']>;
mangaId: Scalars['Int']['input'];
track: TrackSearchTypeInput;
remoteId: Scalars['LongString']['input'];
trackerId: Scalars['Int']['input'];
};
export type BindTrackPayload = {
@@ -1369,6 +1370,11 @@ export type PartialSettingsType = Settings & {
excludeNotStarted?: Maybe<Scalars['Boolean']['output']>;
excludeUnreadChapters?: Maybe<Scalars['Boolean']['output']>;
extensionRepos?: Maybe<Array<Scalars['String']['output']>>;
flareSolverrEnabled?: Maybe<Scalars['Boolean']['output']>;
flareSolverrSessionName?: Maybe<Scalars['String']['output']>;
flareSolverrSessionTtl?: Maybe<Scalars['Int']['output']>;
flareSolverrTimeout?: Maybe<Scalars['Int']['output']>;
flareSolverrUrl?: Maybe<Scalars['String']['output']>;
globalUpdateInterval?: Maybe<Scalars['Float']['output']>;
gqlDebugLogsEnabled?: Maybe<Scalars['Boolean']['output']>;
initialOpenInBrowserEnabled?: Maybe<Scalars['Boolean']['output']>;
@@ -1406,6 +1412,11 @@ export type PartialSettingsTypeInput = {
excludeNotStarted?: InputMaybe<Scalars['Boolean']['input']>;
excludeUnreadChapters?: InputMaybe<Scalars['Boolean']['input']>;
extensionRepos?: InputMaybe<Array<Scalars['String']['input']>>;
flareSolverrEnabled?: InputMaybe<Scalars['Boolean']['input']>;
flareSolverrSessionName?: InputMaybe<Scalars['String']['input']>;
flareSolverrSessionTtl?: InputMaybe<Scalars['Int']['input']>;
flareSolverrTimeout?: InputMaybe<Scalars['Int']['input']>;
flareSolverrUrl?: InputMaybe<Scalars['String']['input']>;
globalUpdateInterval?: InputMaybe<Scalars['Float']['input']>;
gqlDebugLogsEnabled?: InputMaybe<Scalars['Boolean']['input']>;
initialOpenInBrowserEnabled?: InputMaybe<Scalars['Boolean']['input']>;
@@ -1746,6 +1757,11 @@ export type Settings = {
excludeNotStarted?: Maybe<Scalars['Boolean']['output']>;
excludeUnreadChapters?: Maybe<Scalars['Boolean']['output']>;
extensionRepos?: Maybe<Array<Scalars['String']['output']>>;
flareSolverrEnabled?: Maybe<Scalars['Boolean']['output']>;
flareSolverrSessionName?: Maybe<Scalars['String']['output']>;
flareSolverrSessionTtl?: Maybe<Scalars['Int']['output']>;
flareSolverrTimeout?: Maybe<Scalars['Int']['output']>;
flareSolverrUrl?: Maybe<Scalars['String']['output']>;
globalUpdateInterval?: Maybe<Scalars['Float']['output']>;
gqlDebugLogsEnabled?: Maybe<Scalars['Boolean']['output']>;
initialOpenInBrowserEnabled?: Maybe<Scalars['Boolean']['output']>;
@@ -1784,6 +1800,11 @@ export type SettingsType = Settings & {
excludeNotStarted: Scalars['Boolean']['output'];
excludeUnreadChapters: Scalars['Boolean']['output'];
extensionRepos: Array<Scalars['String']['output']>;
flareSolverrEnabled: Scalars['Boolean']['output'];
flareSolverrSessionName: Scalars['String']['output'];
flareSolverrSessionTtl: Scalars['Int']['output'];
flareSolverrTimeout: Scalars['Int']['output'];
flareSolverrUrl: Scalars['String']['output'];
globalUpdateInterval: Scalars['Float']['output'];
gqlDebugLogsEnabled: Scalars['Boolean']['output'];
initialOpenInBrowserEnabled: Scalars['Boolean']['output'];
@@ -1983,9 +2004,9 @@ export type TrackRecordConditionInput = {
score?: InputMaybe<Scalars['Float']['input']>;
startDate?: InputMaybe<Scalars['LongString']['input']>;
status?: InputMaybe<Scalars['Int']['input']>;
syncId?: InputMaybe<Scalars['Int']['input']>;
title?: InputMaybe<Scalars['String']['input']>;
totalChapters?: InputMaybe<Scalars['Int']['input']>;
trackerId?: InputMaybe<Scalars['Int']['input']>;
};
export type TrackRecordEdge = Edge & {
@@ -2008,9 +2029,9 @@ export type TrackRecordFilterInput = {
score?: InputMaybe<DoubleFilterInput>;
startDate?: InputMaybe<LongFilterInput>;
status?: InputMaybe<IntFilterInput>;
syncId?: InputMaybe<IntFilterInput>;
title?: InputMaybe<StringFilterInput>;
totalChapters?: InputMaybe<IntFilterInput>;
trackerId?: InputMaybe<IntFilterInput>;
};
export type TrackRecordNodeList = NodeList & {
@@ -2029,9 +2050,9 @@ export enum TrackRecordOrderBy {
RemoteId = 'REMOTE_ID',
Score = 'SCORE',
StartDate = 'START_DATE',
SyncId = 'SYNC_ID',
Title = 'TITLE',
TotalChapters = 'TOTAL_CHAPTERS'
TotalChapters = 'TOTAL_CHAPTERS',
TrackerId = 'TRACKER_ID'
}
export type TrackRecordType = {
@@ -2048,38 +2069,32 @@ export type TrackRecordType = {
score: Scalars['Float']['output'];
startDate: Scalars['LongString']['output'];
status: Scalars['Int']['output'];
syncId: Scalars['Int']['output'];
title: Scalars['String']['output'];
totalChapters: Scalars['Int']['output'];
tracker: TrackerType;
trackerId: Scalars['Int']['output'];
};
export type TrackSearchType = {
__typename?: 'TrackSearchType';
coverUrl: Scalars['String']['output'];
mediaId: Scalars['LongString']['output'];
id: Scalars['Int']['output'];
publishingStatus: Scalars['String']['output'];
publishingType: Scalars['String']['output'];
remoteId: Scalars['LongString']['output'];
startDate: Scalars['String']['output'];
summary: Scalars['String']['output'];
syncId: Scalars['Int']['output'];
title: Scalars['String']['output'];
totalChapters: Scalars['Int']['output'];
tracker: TrackerType;
trackerId: Scalars['Int']['output'];
trackingUrl: Scalars['String']['output'];
};
export type TrackSearchTypeInput = {
coverUrl: Scalars['String']['input'];
mediaId: Scalars['LongString']['input'];
publishingStatus: Scalars['String']['input'];
publishingType: Scalars['String']['input'];
startDate: Scalars['String']['input'];
summary: Scalars['String']['input'];
syncId: Scalars['Int']['input'];
title: Scalars['String']['input'];
totalChapters: Scalars['Int']['input'];
trackingUrl: Scalars['String']['input'];
export type TrackStatusType = {
__typename?: 'TrackStatusType';
name: Scalars['String']['output'];
value: Scalars['Int']['output'];
};
export type TrackerConditionInput = {
@@ -2116,6 +2131,8 @@ export type TrackerType = {
id: Scalars['Int']['output'];
isLoggedIn: Scalars['Boolean']['output'];
name: Scalars['String']['output'];
scores: Array<Scalars['String']['output']>;
statuses: Array<TrackStatusType>;
trackRecords: TrackRecordNodeList;
};
@@ -2756,6 +2773,15 @@ export type GetMangaFetchMutationVariables = Exact<{
export type GetMangaFetchMutation = { __typename?: 'Mutation', fetchManga: { __typename?: 'FetchMangaPayload', clientMutationId?: string | null, manga: { __typename?: 'MangaType', unreadCount: number, downloadCount: number, artist?: string | null, author?: string | null, chaptersLastFetchedAt?: any | null, description?: string | null, genre: Array<string>, id: number, inLibrary: boolean, inLibraryAt: any, initialized: boolean, lastFetchedAt?: any | null, realUrl?: string | null, status: MangaStatus, thumbnailUrl?: string | null, title: string, url: string, lastReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestFetchedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestUploadedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, categories: { __typename?: 'CategoryNodeList', totalCount: number, nodes: Array<{ __typename?: 'CategoryType', default: boolean, id: number, includeInUpdate: IncludeOrExclude, includeInDownload: IncludeOrExclude, name: string, order: number, meta: Array<{ __typename?: 'CategoryMetaType', key: string, value: string }>, mangas: { __typename?: 'MangaNodeList', totalCount: number } }> }, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, meta: Array<{ __typename?: 'MangaMetaType', key: string, value: string }>, source?: { __typename?: 'SourceType', displayName: string, iconUrl: string, id: any, isConfigurable: boolean, isNsfw: boolean, lang: string, name: string, supportsLatest: boolean, extension: { __typename?: 'ExtensionType', pkgName: string, repo?: string | null } } | null } } };
export type GetMangaToMigrateToFetchMutationVariables = Exact<{
id: Scalars['Int']['input'];
migrateChapters: Scalars['Boolean']['input'];
migrateCategories: Scalars['Boolean']['input'];
}>;
export type GetMangaToMigrateToFetchMutation = { __typename?: 'Mutation', fetchManga: { __typename?: 'FetchMangaPayload', clientMutationId?: string | null, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, categories?: { __typename?: 'CategoryNodeList', nodes: Array<{ __typename?: 'CategoryType', id: number }> } } }, fetchChapters?: { __typename?: 'FetchChaptersPayload', chapters: Array<{ __typename?: 'ChapterType', id: number, chapterNumber: number, isRead: boolean, isDownloaded: boolean, isBookmarked: boolean, manga: { __typename?: 'MangaType', id: number } }> } };
export type SetMangaMetadataMutationVariables = Exact<{
input: SetMangaMetaInput;
}>;
@@ -2983,6 +3009,15 @@ export type GetMangaQueryVariables = Exact<{
export type GetMangaQuery = { __typename?: 'Query', manga: { __typename?: 'MangaType', unreadCount: number, downloadCount: number, artist?: string | null, author?: string | null, chaptersLastFetchedAt?: any | null, description?: string | null, genre: Array<string>, id: number, inLibrary: boolean, inLibraryAt: any, initialized: boolean, lastFetchedAt?: any | null, realUrl?: string | null, status: MangaStatus, thumbnailUrl?: string | null, title: string, url: string, lastReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestFetchedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestUploadedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, categories: { __typename?: 'CategoryNodeList', totalCount: number, nodes: Array<{ __typename?: 'CategoryType', default: boolean, id: number, includeInUpdate: IncludeOrExclude, includeInDownload: IncludeOrExclude, name: string, order: number, meta: Array<{ __typename?: 'CategoryMetaType', key: string, value: string }>, mangas: { __typename?: 'MangaNodeList', totalCount: number } }> }, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, meta: Array<{ __typename?: 'MangaMetaType', key: string, value: string }>, source?: { __typename?: 'SourceType', displayName: string, iconUrl: string, id: any, isConfigurable: boolean, isNsfw: boolean, lang: string, name: string, supportsLatest: boolean, extension: { __typename?: 'ExtensionType', pkgName: string, repo?: string | null } } | null } };
export type GetMangaToMigrateQueryVariables = Exact<{
id: Scalars['Int']['input'];
migrateChapters: Scalars['Boolean']['input'];
migrateCategories: Scalars['Boolean']['input'];
}>;
export type GetMangaToMigrateQuery = { __typename?: 'Query', manga: { __typename?: 'MangaType', id: number, inLibrary: boolean, title: string, chapters?: { __typename?: 'ChapterNodeList', totalCount: number, nodes: Array<{ __typename?: 'ChapterType', id: number, chapterNumber: number, isRead: boolean, isDownloaded: boolean, isBookmarked: boolean, manga: { __typename?: 'MangaType', id: number } }> }, categories?: { __typename?: 'CategoryNodeList', nodes: Array<{ __typename?: 'CategoryType', id: number }> } } };
export type GetMangasQueryVariables = Exact<{
after?: InputMaybe<Scalars['Cursor']['input']>;
before?: InputMaybe<Scalars['Cursor']['input']>;
@@ -2998,6 +3033,13 @@ export type GetMangasQueryVariables = Exact<{
export type GetMangasQuery = { __typename?: 'Query', mangas: { __typename?: 'MangaNodeList', totalCount: number, nodes: Array<{ __typename?: 'MangaType', unreadCount: number, downloadCount: number, artist?: string | null, author?: string | null, chaptersLastFetchedAt?: any | null, description?: string | null, genre: Array<string>, id: number, inLibrary: boolean, inLibraryAt: any, initialized: boolean, lastFetchedAt?: any | null, realUrl?: string | null, status: MangaStatus, thumbnailUrl?: string | null, title: string, url: string, lastReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestFetchedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestUploadedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, categories: { __typename?: 'CategoryNodeList', totalCount: number, nodes: Array<{ __typename?: 'CategoryType', default: boolean, id: number, includeInUpdate: IncludeOrExclude, includeInDownload: IncludeOrExclude, name: string, order: number, meta: Array<{ __typename?: 'CategoryMetaType', key: string, value: string }>, mangas: { __typename?: 'MangaNodeList', totalCount: number } }> }, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, meta: Array<{ __typename?: 'MangaMetaType', key: string, value: string }>, source?: { __typename?: 'SourceType', displayName: string, iconUrl: string, id: any, isConfigurable: boolean, isNsfw: boolean, lang: string, name: string, supportsLatest: boolean, extension: { __typename?: 'ExtensionType', pkgName: string, repo?: string | null } } | null }>, pageInfo: { __typename?: 'PageInfo', endCursor?: any | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: any | null } } };
export type GetMigratableSourceMangasQueryVariables = Exact<{
sourceId: Scalars['LongString']['input'];
}>;
export type GetMigratableSourceMangasQuery = { __typename?: 'Query', mangas: { __typename?: 'MangaNodeList', nodes: Array<{ __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null, source?: { __typename?: 'SourceType', id: any } | null, categories: { __typename?: 'CategoryNodeList', nodes: Array<{ __typename?: 'CategoryType', id: number }> } }> } };
export type GetAboutQueryVariables = Exact<{ [key: string]: never; }>;
@@ -3035,6 +3077,11 @@ export type GetSourcesQueryVariables = Exact<{ [key: string]: never; }>;
export type GetSourcesQuery = { __typename?: 'Query', sources: { __typename?: 'SourceNodeList', nodes: Array<{ __typename?: 'SourceType', displayName: string, iconUrl: string, id: any, isConfigurable: boolean, isNsfw: boolean, lang: string, name: string, supportsLatest: boolean, extension: { __typename?: 'ExtensionType', pkgName: string, repo?: string | null } }> } };
export type GetMigratableSourcesQueryVariables = Exact<{ [key: string]: never; }>;
export type GetMigratableSourcesQuery = { __typename?: 'Query', mangas: { __typename?: 'MangaNodeList', nodes: Array<{ __typename?: 'MangaType', sourceId: any, source?: { __typename?: 'SourceType', id: any, name: string, lang: string, iconUrl: string } | null }> } };
export type GetUpdateStatusQueryVariables = Exact<{ [key: string]: never; }>;

View File

@@ -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) {

View File

@@ -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
}
}
}
}
}
`;

View File

@@ -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
}
}
}
}
`;

View File

@@ -172,6 +172,14 @@ import {
UpdateMangaCategoriesPatchInput,
GetWebuiUpdateStatusQuery,
GetWebuiUpdateStatusQueryVariables,
GetMigratableSourcesQuery,
GetMigratableSourcesQueryVariables,
GetMigratableSourceMangasQuery,
GetMigratableSourceMangasQueryVariables,
GetMangaToMigrateQuery,
GetMangaToMigrateQueryVariables,
GetMangaToMigrateToFetchMutation,
GetMangaToMigrateToFetchMutationVariables,
} from '@/lib/graphql/generated/graphql.ts';
import { GET_GLOBAL_METADATAS } from '@/lib/graphql/queries/GlobalMetadataQuery.ts';
import { SET_GLOBAL_METADATA } from '@/lib/graphql/mutations/GlobalMetadataMutation.ts';
@@ -187,16 +195,22 @@ import {
INSTALL_EXTERNAL_EXTENSION,
UPDATE_EXTENSION,
} from '@/lib/graphql/mutations/ExtensionMutation.ts';
import { GET_SOURCE, GET_SOURCES } from '@/lib/graphql/queries/SourceQuery.ts';
import { GET_MIGRATABLE_SOURCES, GET_SOURCE, GET_SOURCES } from '@/lib/graphql/queries/SourceQuery.ts';
import {
GET_MANGA_FETCH,
GET_MANGA_TO_MIGRATE_TO_FETCH,
SET_MANGA_METADATA,
UPDATE_MANGA,
UPDATE_MANGA_CATEGORIES,
UPDATE_MANGAS,
UPDATE_MANGAS_CATEGORIES,
} from '@/lib/graphql/mutations/MangaMutation.ts';
import { GET_MANGA, GET_MANGAS } from '@/lib/graphql/queries/MangaQuery.ts';
import {
GET_MANGA,
GET_MANGA_TO_MIGRATE,
GET_MANGAS,
GET_MIGRATABLE_SOURCE_MANGAS,
} from '@/lib/graphql/queries/MangaQuery.ts';
import { GET_CATEGORIES, GET_CATEGORY_MANGAS } from '@/lib/graphql/queries/CategoryQuery.ts';
import { GET_SOURCE_MANGAS_FETCH, UPDATE_SOURCE_PREFERENCES } from '@/lib/graphql/mutations/SourceMutation.ts';
import {
@@ -1330,6 +1344,33 @@ export class RequestManager {
return this.doRequest(GQLMethod.USE_QUERY, GET_MANGA, { id: Number(mangaId) }, options);
}
public getManga(
mangaId: number | string,
options?: QueryOptions<GetMangaQueryVariables, GetMangaQuery>,
): AbortabaleApolloQueryResponse<GetMangaQuery> {
return this.doRequest(GQLMethod.QUERY, GET_MANGA, { id: Number(mangaId) }, options);
}
public getMangaToMigrate(
mangaId: number | string,
{
migrateChapters = false,
migrateCategories = false,
apolloOptions: options,
}: {
migrateChapters?: boolean;
migrateCategories?: boolean;
apolloOptions?: QueryOptions<GetMangaToMigrateQueryVariables, GetMangaToMigrateQuery>;
} = {},
): AbortabaleApolloQueryResponse<GetMangaToMigrateQuery> {
return this.doRequest(
GQLMethod.QUERY,
GET_MANGA_TO_MIGRATE,
{ id: Number(mangaId), migrateChapters, migrateCategories },
options,
);
}
public getMangaFetch(
mangaId: number | string,
options?: MutationOptions<GetMangaFetchMutation, GetMangaFetchMutationVariables>,
@@ -1346,6 +1387,33 @@ export class RequestManager {
);
}
public getMangaToMigrateToFetch(
mangaId: number | string,
{
migrateChapters = false,
migrateCategories = false,
apolloOptions: options,
}: {
migrateChapters?: boolean;
migrateCategories?: boolean;
apolloOptions?: MutationOptions<
GetMangaToMigrateToFetchMutation,
GetMangaToMigrateToFetchMutationVariables
>;
} = {},
): AbortableApolloMutationResponse<GetMangaToMigrateToFetchMutation> {
return this.doRequest<GetMangaToMigrateToFetchMutation, GetMangaToMigrateToFetchMutationVariables>(
GQLMethod.MUTATION,
GET_MANGA_TO_MIGRATE_TO_FETCH,
{
id: Number(mangaId),
migrateChapters,
migrateCategories,
},
options,
);
}
public useGetMangas(
variables: GetMangasQueryVariables,
options?: QueryHookOptions<GetMangasQuery, GetMangasQueryVariables>,
@@ -1360,6 +1428,13 @@ export class RequestManager {
return this.doRequest(GQLMethod.QUERY, GET_MANGAS, variables, options);
}
public useGetMigratableSourceMangas(
sourceId: string,
options?: QueryHookOptions<GetMigratableSourceMangasQuery, GetMigratableSourceMangasQueryVariables>,
): AbortableApolloUseQueryResponse<GetMigratableSourceMangasQuery, GetMigratableSourceMangasQueryVariables> {
return this.doRequest(GQLMethod.USE_QUERY, GET_MIGRATABLE_SOURCE_MANGAS, { sourceId }, options);
}
public getMangaThumbnailUrl(mangaId: number): string {
return this.getValidImgUrlFor(`manga/${mangaId}/thumbnail`);
}
@@ -2182,6 +2257,12 @@ export class RequestManager {
): AbortableApolloUseQueryResponse<GetWebuiUpdateStatusQuery, GetWebuiUpdateStatusQueryVariables> {
return this.doRequest(GQLMethod.USE_QUERY, GET_WEBUI_UPDATE_STATUS, undefined, options);
}
public useGetMigratableSources(
options?: QueryHookOptions<GetMigratableSourcesQuery, GetMigratableSourcesQueryVariables>,
): AbortableApolloUseQueryResponse<GetMigratableSourcesQuery, GetMigratableSourcesQueryVariables> {
return this.doRequest(GQLMethod.USE_QUERY, GET_MIGRATABLE_SOURCES, undefined, options);
}
}
export const requestManager = new RequestManager();

View File

@@ -6,7 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useState } from 'react';
import { useContext, useEffect, useState } from 'react';
import Tab from '@mui/material/Tab';
import { useTranslation } from 'react-i18next';
import { Sources } from '@/screens/Sources';
@@ -14,17 +14,25 @@ import { Extensions } from '@/screens/Extensions';
import { TabPanel } from '@/components/tabs/TabPanel.tsx';
import { TabsWrapper } from '@/components/tabs/TabsWrapper.tsx';
import { TabsMenu } from '@/components/tabs/TabsMenu.tsx';
import { Migration } from '@/screens/Migration.tsx';
import { NavBarContext } from '@/components/context/NavbarContext.tsx';
export function Browse() {
const { t } = useTranslation();
const { setTitle } = useContext(NavBarContext);
const [tabNum, setTabNum] = useState<number>(0);
useEffect(() => {
setTitle(t('global.label.browse'));
}, [t]);
return (
<TabsWrapper>
<TabsMenu value={tabNum} tabsCount={2} onChange={(e, newTab) => setTabNum(newTab)}>
<Tab sx={{ textTransform: 'none' }} label={t('source.title')} />
<Tab sx={{ textTransform: 'none' }} label={t('extension.title')} />
<Tab sx={{ textTransform: 'none' }} label={t('migrate.title')} />
</TabsMenu>
<TabPanel index={0} currentIndex={tabNum}>
<Sources />
@@ -32,6 +40,9 @@ export function Browse() {
<TabPanel index={1} currentIndex={tabNum}>
<Extensions />
</TabPanel>
<TabPanel index={2} currentIndex={tabNum}>
<Migration />
</TabPanel>
</TabsWrapper>
);
}

View File

@@ -142,6 +142,11 @@ export const DownloadQueue: React.FC = () => {
</Tooltip>
</>,
);
return () => {
setTitle('');
setAction(null);
};
}, [t, status, isQueueEmpty]);
useEffect(() => {

View File

@@ -95,6 +95,7 @@ function getExtensionsInfo(extensions: PartialExtension[]): {
export function Extensions() {
const { t } = useTranslation();
const { setAction } = useContext(NavBarContext);
const theme = useTheme();
const isMobileWidth = useMediaQuery(theme.breakpoints.down('sm'));
@@ -104,7 +105,6 @@ export function Extensions() {
const areMultipleReposInUse = (serverSettingsData?.settings.extensionRepos.length ?? 0) > 1;
const inputRef = useRef<HTMLInputElement>(null);
const { setTitle, setAction } = useContext(NavBarContext);
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownExtensionLangs', extensionDefaultLangs());
const [showNsfw] = useLocalStorage<boolean>('showNsfw', true);
const [query] = useQueryParam('query', StringParam);
@@ -169,7 +169,6 @@ export function Extensions() {
};
useEffect(() => {
setTitle(t('extension.title'));
setAction(
<>
<AppbarSearch />
@@ -182,6 +181,10 @@ export function Extensions() {
<LangSelect shownLangs={shownLangs} setShownLangs={setShownLangs} allLangs={allLangs} />
</>,
);
return () => {
setAction(null);
};
}, [t, shownLangs, allLangs]);
useEffect(() => {

View File

@@ -58,6 +58,11 @@ export const Manga: React.FC = () => {
useEffect(() => {
setTitle(manga?.title ?? t('manga.title'));
setAction(null);
return () => {
setTitle('');
setAction(null);
};
}, [t, manga?.title]);
useEffect(() => {
@@ -86,6 +91,10 @@ export const Manga: React.FC = () => {
{manga && <MangaToolbarMenu manga={manga} onRefresh={refresh} refreshing={refreshing} />}
</Stack>,
);
return () => {
setAction(null);
};
}, [t, error, isValidating, refreshing, manga, refresh]);
if (error && !manga) {

110
src/screens/Migrate.tsx Normal file
View File

@@ -0,0 +1,110 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useContext, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import gql from 'graphql-tag';
import { useTranslation } from 'react-i18next';
import { NavBarContext } from '@/components/context/NavbarContext.tsx';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { TMigratableSource } from '@/components/MigrationCard.tsx';
import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder.tsx';
import { EmptyView } from '@/components/util/EmptyView.tsx';
import { MangaGrid } from '@/components/MangaGrid.tsx';
import { TPartialManga } from '@/typings.ts';
import { GridLayouts } from '@/components/source/GridLayouts.tsx';
import { useLocalStorage } from '@/util/useLocalStorage.tsx';
import { GridLayout } from '@/components/context/LibraryOptionsContext.tsx';
export const Migrate = () => {
const { t } = useTranslation();
const { setTitle, setAction } = useContext(NavBarContext);
const { sourceId: paramSourceId } = useParams<{ sourceId: string }>();
const [gridLayout, setGridLayout] = useLocalStorage('migrateGridLayout', GridLayout.List);
const fragmentSource = requestManager.graphQLClient.client.cache.readFragment<
Pick<TMigratableSource, 'id' | 'name'>
>({
id: requestManager.graphQLClient.client.cache.identify({ __typename: 'SourceType', id: paramSourceId }),
fragment: gql`
fragment MigratableSource on SourceType {
id
name
}
`,
});
const [isKnownSource, setIsKnownSource] = useState(fragmentSource !== null ? true : undefined);
const {
data: migratableSourceData,
loading: isSourceLoading,
error: sourceError,
} = requestManager.useGetSource(paramSourceId, { skip: !!isKnownSource });
const { sourceId, name } = {
sourceId: paramSourceId,
name: paramSourceId,
...fragmentSource,
...migratableSourceData?.source,
};
const {
data: migratableSourceMangasData,
loading: areMangasLoading,
error: mangasError,
} = requestManager.useGetMigratableSourceMangas(sourceId, {
skip: !isKnownSource,
});
useEffect(() => {
setTitle(name ?? sourceId ?? t('migrate.title'));
setAction(<GridLayouts gridLayout={gridLayout} onChange={setGridLayout} />);
return () => {
setTitle('');
setAction(null);
};
}, [t, name, sourceId, gridLayout]);
useEffect(() => {
if (isSourceLoading || isKnownSource) {
return;
}
setIsKnownSource(
!!migratableSourceData ||
!!sourceError?.message.includes("The field at path '/source' was declared as a non null type"),
);
}, [isSourceLoading, sourceError]);
const isLoadingSource = isSourceLoading || (!sourceError && !isKnownSource);
const isLoading = isLoadingSource || areMangasLoading;
if (isLoading) {
return <LoadingPlaceholder />;
}
const hasErrorSource = sourceError && isKnownSource === false;
const hasError = hasErrorSource || mangasError;
if (hasError) {
const error = (hasErrorSource ? sourceError : mangasError)!;
return <EmptyView message={t('global.error.label.failed_to_load_data')} messageExtra={error.message} />;
}
return (
<MangaGrid
hasNextPage={false}
loadMore={() => {}}
isLoading={areMangasLoading}
mangas={(migratableSourceMangasData?.mangas.nodes ?? []) as TPartialManga[]}
gridLayout={gridLayout}
mode="migrate.search"
/>
);
};

69
src/screens/Migration.tsx Normal file
View File

@@ -0,0 +1,69 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useTranslation } from 'react-i18next';
import { useMemo } from 'react';
import List from '@mui/material/List';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder.tsx';
import { EmptyView } from '@/components/util/EmptyView.tsx';
import { GetMigratableSourcesQuery } from '@/lib/graphql/generated/graphql.ts';
import { MigrationCard, TMigratableSource } from '@/components/MigrationCard.tsx';
import { StyledGroupItemWrapper } from '@/components/virtuoso/StyledGroupItemWrapper.tsx';
type TMigratableSourcesResult = GetMigratableSourcesQuery['mangas']['nodes'];
type TMigratableSources = Record<string, TMigratableSource>;
const getMigratableSources = (mangas?: TMigratableSourcesResult): TMigratableSources => {
if (!mangas) {
return {};
}
const uniqueSources: TMigratableSources = {};
mangas.forEach(({ sourceId, source }) => {
const uniqueSource = uniqueSources[sourceId] ?? {
...{ id: sourceId, name: sourceId, lang: 'unknown', iconUrl: null, mangaCount: 0, ...source },
};
uniqueSources[sourceId] = {
...uniqueSource,
mangaCount: uniqueSource.mangaCount + 1,
};
});
return uniqueSources;
};
export const Migration = () => {
const { t } = useTranslation();
const { data, loading, error } = requestManager.useGetMigratableSources();
const migratableSources = useMemo(() => getMigratableSources(data?.mangas.nodes), [data?.mangas.nodes]);
if (loading) {
return <LoadingPlaceholder />;
}
if (error) {
return <EmptyView message={t('global.error.label.failed_to_load_data')} messageExtra={error.message} />;
}
return (
<List>
{Object.values(migratableSources).map((migratableSource, index) => (
<StyledGroupItemWrapper
key={migratableSource.id}
isLastItem={index === Object.values(migratableSources).length - 1}
>
<MigrationCard {...migratableSource} />
</StyledGroupItemWrapper>
))}
</List>
);
};

View File

@@ -8,7 +8,7 @@
import { Card, CardActionArea, Typography } from '@mui/material';
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import { StringParam, useQueryParam } from 'use-query-params';
import { useTranslation } from 'react-i18next';
import { ISource } from '@/typings';
@@ -21,6 +21,7 @@ import { LangSelect } from '@/components/navbar/action/LangSelect';
import { MangaGrid } from '@/components/MangaGrid';
import { useDebounce } from '@/util/useDebounce.ts';
import { NavBarContext, useSetDefaultBackTo } from '@/components/context/NavbarContext.tsx';
import { MangaCardProps } from '@/components/MangaCard.tsx';
type SourceLoadingState = { isLoading: boolean; hasResults: boolean; emptySearch: boolean };
type SourceToLoadingStateMap = Map<string, SourceLoadingState>;
@@ -84,6 +85,7 @@ const SourceSearchPreview = React.memo(
onSearchRequestFinished,
searchString,
emptyQuery,
mode,
}: {
source: ISource;
onSearchRequestFinished: (
@@ -94,7 +96,7 @@ const SourceSearchPreview = React.memo(
) => void;
searchString: string | null | undefined;
emptyQuery: boolean;
}) => {
} & Pick<MangaCardProps, 'mode'>) => {
const { t } = useTranslation();
const { id, displayName, lang } = source;
@@ -150,6 +152,7 @@ const SourceSearchPreview = React.memo(
noFaces
message={errorMessage}
inLibraryIndicator
mode={mode}
/>
</>
);
@@ -163,6 +166,11 @@ export const SearchAll: React.FC = () => {
useSetDefaultBackTo('sources');
const { pathname, state } = useLocation<{ mangaTitle?: string }>();
const isMigrateMode = pathname.startsWith('/migrate/source');
const mangaTitle = state?.mangaTitle;
const [query] = useQueryParam('query', StringParam);
const searchString = useDebounce(query, TRIGGER_SEARCH_THRESHOLD);
@@ -203,7 +211,7 @@ export const SearchAll: React.FC = () => {
);
useEffect(() => {
setTitle(t('search.title.global_search'));
setTitle(t(isMigrateMode ? 'migrate.search.title' : 'search.title.global_search', { title: mangaTitle }));
setAction(
<>
<AppbarSearch autoOpen />
@@ -215,6 +223,11 @@ export const SearchAll: React.FC = () => {
/>
</>,
);
return () => {
setTitle('');
setAction(null);
};
}, [t, shownLangs, setShownLangs, sources]);
useEffect(() => {
@@ -234,6 +247,7 @@ export const SearchAll: React.FC = () => {
onSearchRequestFinished={updateSourceLoadingState}
searchString={searchString}
emptyQuery={!query}
mode={isMigrateMode ? 'migrate.select' : undefined}
/>
))}
</>

View File

@@ -43,6 +43,11 @@ export function Settings() {
useEffect(() => {
setTitle(t('settings.title'));
setAction(null);
return () => {
setTitle('');
setAction(null);
};
}, [t]);
const { darkTheme, setDarkTheme } = useContext(DarkTheme);
@@ -68,7 +73,7 @@ export function Settings() {
<ListItemIcon>
<ListAltIcon />
</ListItemIcon>
<ListItemText primary={t('category.title.categories')} />
<ListItemText primary={t('category.title.category_other')} />
</ListItemLink>
<ListItemLink to="/settings/defaultReaderSettings">
<ListItemIcon>

View File

@@ -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 }>();

View File

@@ -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(() => {

View File

@@ -48,7 +48,7 @@ function groupByLang(sources: ISource[]) {
export function Sources() {
const { t } = useTranslation();
const { setTitle, setAction } = useContext(NavBarContext);
const { setAction } = useContext(NavBarContext);
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownSourceLangs', sourceDefualtLangs());
const [showNsfw] = useLocalStorage<boolean>('showNsfw', true);
@@ -84,7 +84,6 @@ export function Sources() {
}, []);
useEffect(() => {
setTitle(t('source.title'));
setAction(
<>
<Tooltip title={t('search.title.global_search')}>
@@ -100,6 +99,10 @@ export function Sources() {
/>
</>,
);
return () => {
setAction(null);
};
}, [t, shownLangs, sources]);
if (isLoading) return <LoadingPlaceholder />;

View File

@@ -104,8 +104,12 @@ export const Updates: React.FC = () => {
useEffect(() => {
setTitle(t('updates.title'));
setAction(<UpdateChecker />);
return () => {
setTitle('');
setAction(null);
};
}, [t, lastUpdateTimestamp]);
const downloadForChapter = (chapter: TChapter) => {

View File

@@ -190,6 +190,11 @@ export function About() {
useEffect(() => {
setTitle(t('settings.about.title'));
setAction(null);
return () => {
setTitle('');
setAction(null);
};
}, [t]);
useSetDefaultBackTo('settings');

View File

@@ -62,6 +62,11 @@ export function Backup() {
useEffect(() => {
setTitle(t('settings.backup.title'));
setAction(null);
return () => {
setTitle('');
setAction(null);
};
}, [t]);
useSetDefaultBackTo('settings');

View File

@@ -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();

View File

@@ -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();

View File

@@ -48,6 +48,11 @@ export const DownloadSettings = () => {
useEffect(() => {
setTitle(t('download.settings.title'));
setAction(null);
return () => {
setTitle('');
setAction(null);
};
}, [t]);
const { data } = requestManager.useGetServerSettings();

View File

@@ -46,6 +46,11 @@ export function LibrarySettings() {
useEffect(() => {
setTitle(t('library.settings.title'));
setAction(null);
return () => {
setTitle('');
setAction(null);
};
}, [t]);
useSetDefaultBackTo('settings');

View File

@@ -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();

View File

@@ -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();