Feature/streamline backend requests (#297)
* Introduce "RequestManager"
* Use "RequestManager" - Simple replacements
- Get rid of all "util/client" imports
- replace old requests with new "RequestManager"
- remove "fetcher" from global SWR config
instead of using the SWR hooks by themselves, the "requestManager" is supposed to be used
* Use "RequestManager" - Do requests via SWR hooks
Use SWR hooks at places where it's easily usable
* Prevent trailing slashes in the "baseUrl"
This commit is contained in:
@@ -12,11 +12,10 @@ import CardContent from '@mui/material/CardContent';
|
||||
import Button from '@mui/material/Button';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import client from 'util/client';
|
||||
import useLocalStorage from 'util/useLocalStorage';
|
||||
import { Box } from '@mui/system';
|
||||
import { IExtension, TranslationKey } from 'typings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
interface IProps {
|
||||
extension: IExtension;
|
||||
@@ -79,9 +78,6 @@ export default function ExtensionCard(props: IProps) {
|
||||
return installed ? InstalledState.UNINSTALL : InstalledState.INSTALL;
|
||||
});
|
||||
|
||||
const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
|
||||
const [useCache] = useLocalStorage<boolean>('useCache', true);
|
||||
|
||||
const langPress = lang === 'all' ? t('extension.language.all') : lang.toUpperCase();
|
||||
|
||||
const requestExtensionAction = async (action: ExtensionAction): Promise<void> => {
|
||||
@@ -89,7 +85,19 @@ export default function ExtensionCard(props: IProps) {
|
||||
const state = EXTENSION_ACTION_TO_STATE_MAP[action];
|
||||
|
||||
setInstalledState(state);
|
||||
await client.get(`/api/v1/extension/${action.toLowerCase()}/${pkgName}`);
|
||||
switch (action) {
|
||||
case ExtensionAction.INSTALL:
|
||||
await requestManager.installExtension(pkgName);
|
||||
break;
|
||||
case ExtensionAction.UNINSTALL:
|
||||
await requestManager.uninstallExtension(pkgName);
|
||||
break;
|
||||
case ExtensionAction.UPDATE:
|
||||
await requestManager.updateExtension(pkgName);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unexpected ExtensionAction "${action}"`);
|
||||
}
|
||||
setInstalledState(nextAction);
|
||||
notifyInstall();
|
||||
};
|
||||
@@ -129,7 +137,7 @@ export default function ExtensionCard(props: IProps) {
|
||||
mr: 2,
|
||||
}}
|
||||
alt={name}
|
||||
src={`${serverAddress}${iconUrl}?useCache=${useCache}`}
|
||||
src={requestManager.getValidImgUrlFor(iconUrl)}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="h5" component="h2">
|
||||
|
||||
@@ -19,6 +19,7 @@ import { GridLayout, useLibraryOptionsContext } from 'components/context/Library
|
||||
import { BACK } from 'util/useBackTo';
|
||||
import { IMangaCard } from 'typings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
const BottomGradient = styled('div')({
|
||||
position: 'absolute',
|
||||
@@ -87,8 +88,6 @@ const MangaCard = React.forwardRef<HTMLDivElement, IProps>((props: IProps, ref)
|
||||
options: { showUnreadBadge, showDownloadBadge },
|
||||
} = useLibraryOptionsContext();
|
||||
|
||||
const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
|
||||
const [useCache] = useLocalStorage<boolean>('useCache', true);
|
||||
const [ItemWidth] = useLocalStorage<number>('ItemWidth', 300);
|
||||
|
||||
const mangaLinkTo = { pathname: `/manga/${id}/`, state: { backLink: BACK } };
|
||||
@@ -145,7 +144,7 @@ const MangaCard = React.forwardRef<HTMLDivElement, IProps>((props: IProps, ref)
|
||||
</BadgeContainer>
|
||||
<SpinnerImage
|
||||
alt={title}
|
||||
src={`${serverAddress}${thumbnailUrl}?useCache=${useCache}`}
|
||||
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
|
||||
imgStyle={
|
||||
inLibraryIndicator && inLibrary
|
||||
? {
|
||||
@@ -232,7 +231,7 @@ const MangaCard = React.forwardRef<HTMLDivElement, IProps>((props: IProps, ref)
|
||||
imageRendering: 'pixelated',
|
||||
}
|
||||
}
|
||||
src={`${serverAddress}${thumbnailUrl}?useCache=${useCache}`}
|
||||
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
|
||||
@@ -16,9 +16,9 @@ import { Box, styled } from '@mui/system';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
import useLocalStorage from 'util/useLocalStorage';
|
||||
import { ISource } from 'typings';
|
||||
import { translateExtensionLanguage } from 'screens/util/Extensions';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
const MobileWidthButtons = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
@@ -51,9 +51,6 @@ const SourceCard: React.FC<IProps> = (props: IProps) => {
|
||||
|
||||
const history = useHistory();
|
||||
|
||||
const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
|
||||
const [useCache] = useLocalStorage<boolean>('useCache', true);
|
||||
|
||||
const redirectTo = (e: any, to: string) => {
|
||||
history.push(to);
|
||||
|
||||
@@ -86,7 +83,7 @@ const SourceCard: React.FC<IProps> = (props: IProps) => {
|
||||
flex: '0 0 auto',
|
||||
mr: 2,
|
||||
}}
|
||||
src={`${serverAddress}${iconUrl}?useCache=${useCache}`}
|
||||
src={requestManager.getValidImgUrlFor(iconUrl)}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
|
||||
@@ -14,7 +14,6 @@ import { BrowserRouter as Router, Route } from 'react-router-dom';
|
||||
import { SWRConfig } from 'swr';
|
||||
import createTheme from 'theme';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
import { fetcher } from 'util/client';
|
||||
import useLocalStorage from 'util/useLocalStorage';
|
||||
import DarkTheme from 'components/context/DarkTheme';
|
||||
|
||||
@@ -36,7 +35,7 @@ const AppContext: React.FC<Props> = ({ children }) => {
|
||||
const theme = useMemo(() => createTheme(darkTheme), [darkTheme]);
|
||||
|
||||
return (
|
||||
<SWRConfig value={{ fetcher }}>
|
||||
<SWRConfig>
|
||||
<Router>
|
||||
<StyledEngineProvider injectFirst>
|
||||
<ThemeProvider theme={theme}>
|
||||
|
||||
@@ -12,10 +12,10 @@ import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { Box } from '@mui/system';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import client from 'util/client';
|
||||
import makeToast from 'components/util/Toast';
|
||||
import { IUpdateStatus } from 'typings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
interface IProgressProps {
|
||||
progress: number;
|
||||
@@ -32,8 +32,6 @@ function Progress({ progress }: IProgressProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const baseWebsocketUrl = JSON.parse(window.localStorage.getItem('serverBaseURL')!).replace('http', 'ws');
|
||||
|
||||
interface IUpdateCheckerProps {
|
||||
handleFinishedUpdate: (time: number) => void;
|
||||
}
|
||||
@@ -48,7 +46,7 @@ function UpdateChecker({ handleFinishedUpdate }: IUpdateCheckerProps) {
|
||||
try {
|
||||
setLoading(true);
|
||||
setProgress(0);
|
||||
await client.post('/api/v1/update/fetch');
|
||||
await requestManager.startGlobalUpdate();
|
||||
} catch (e) {
|
||||
makeToast(t('global.error.label.update_failed'), 'error');
|
||||
setLoading(false);
|
||||
@@ -56,7 +54,7 @@ function UpdateChecker({ handleFinishedUpdate }: IUpdateCheckerProps) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const wsc = new WebSocket(`${baseWebsocketUrl}/api/v1/update`);
|
||||
const wsc = requestManager.getUpdateWebSocket();
|
||||
|
||||
// "loading" can't be used since it will be outdated once the state gets changed
|
||||
// it could be used by adding it as a dependency of "useEffect" but then the socket would
|
||||
|
||||
@@ -7,14 +7,13 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const baseWebsocketUrl = JSON.parse(window.localStorage.getItem('serverBaseURL')!).replace('http', 'ws');
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
const useSubscription = <T>(path: string, callback?: (newValue: T) => boolean | void) => {
|
||||
const [state, setState] = useState<T | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
const wsc = new WebSocket(`${baseWebsocketUrl}${path}`);
|
||||
const wsc = new WebSocket(requestManager.getValidWebSocketUrl(path));
|
||||
|
||||
wsc.onmessage = (e) => {
|
||||
const data = JSON.parse(e.data) as T;
|
||||
|
||||
@@ -27,11 +27,11 @@ import Typography from '@mui/material/Typography';
|
||||
import DownloadStateIndicator from 'components/molecules/DownloadStateIndicator';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import client from 'util/client';
|
||||
import { BACK } from 'util/useBackTo';
|
||||
import { getUploadDateString } from 'util/date';
|
||||
import { IChapter, IDownloadChapter } from 'typings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
interface IProps {
|
||||
chapter: IChapter;
|
||||
@@ -66,23 +66,23 @@ const ChapterCard: React.FC<IProps> = (props: IProps) => {
|
||||
const sendChange = (key: string, value: any) => {
|
||||
handleClose();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append(key, value);
|
||||
if (key === 'read') {
|
||||
formData.append('lastPageRead', '0');
|
||||
}
|
||||
client
|
||||
.patch(`/api/v1/manga/${chapter.mangaId}/chapter/${chapter.index}`, formData)
|
||||
requestManager
|
||||
.updateChapter(chapter.mangaId, chapter.index, {
|
||||
[key]: value,
|
||||
lastPageRead: key === 'read' ? 0 : undefined,
|
||||
})
|
||||
.then(() => triggerChaptersUpdate());
|
||||
};
|
||||
|
||||
const downloadChapter = () => {
|
||||
client.get(`/api/v1/download/${chapter.mangaId}/chapter/${chapter.index}`);
|
||||
requestManager.addChapterToDownloadQueue(chapter.mangaId, chapter.index);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const deleteChapter = () => {
|
||||
client.delete(`/api/v1/manga/${chapter.mangaId}/chapter/${chapter.index}`).then(() => triggerChaptersUpdate());
|
||||
requestManager
|
||||
.removeChapterFromDownloadQueue(chapter.mangaId, chapter.index)
|
||||
.then(() => triggerChaptersUpdate());
|
||||
handleClose();
|
||||
};
|
||||
|
||||
|
||||
@@ -17,12 +17,12 @@ import EmptyView from 'components/util/EmptyView';
|
||||
import makeToast from 'components/util/Toast';
|
||||
import React, { ComponentProps, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import client, { useQuery } from 'util/client';
|
||||
import ChaptersToolbarMenu from 'components/manga/ChaptersToolbarMenu';
|
||||
import SelectionFAB from 'components/manga/SelectionFAB';
|
||||
import { BatchChaptersChange, IChapter, IDownloadChapter, IQueue, TranslationKey } from 'typings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DEFAULT_FULL_FAB_HEIGHT } from 'components/util/StyledFab';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
const StyledVirtuoso = styled(Virtuoso)(({ theme }) => ({
|
||||
listStyle: 'none',
|
||||
@@ -83,14 +83,10 @@ const ChapterList: React.FC<IProps> = ({ mangaId }) => {
|
||||
|
||||
const [selection, setSelection] = useState<number[] | null>(null);
|
||||
const prevQueueRef = useRef<IDownloadChapter[]>();
|
||||
const queue = useSubscription<IQueue>('/api/v1/downloads').data?.queue;
|
||||
const queue = useSubscription<IQueue>('downloads').data?.queue;
|
||||
|
||||
const [options, dispatch] = useChapterOptions(mangaId);
|
||||
const {
|
||||
data: chaptersData,
|
||||
mutate,
|
||||
isLoading,
|
||||
} = useQuery<IChapter[]>(`/api/v1/manga/${mangaId}/chapters?onlineFetch=false`);
|
||||
const { data: chaptersData, mutate, isLoading } = requestManager.useGetMangaChapters(mangaId);
|
||||
const chapters = useMemo(() => chaptersData ?? [], [chaptersData]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -157,7 +153,7 @@ const ChapterList: React.FC<IProps> = ({ mangaId }) => {
|
||||
let actionPromise: Promise<any>;
|
||||
|
||||
if (action === 'download') {
|
||||
actionPromise = client.post('/api/v1/download/batch', { chapterIds });
|
||||
actionPromise = requestManager.addChaptersToDownloadQueue(chapterIds);
|
||||
} else {
|
||||
const change: BatchChaptersChange = {};
|
||||
|
||||
@@ -169,7 +165,7 @@ const ChapterList: React.FC<IProps> = ({ mangaId }) => {
|
||||
change.lastPageRead = 0;
|
||||
}
|
||||
|
||||
actionPromise = client.post('/api/v1/chapter/batch', { chapterIds, change });
|
||||
actionPromise = requestManager.updateChapters(chapterIds, change);
|
||||
}
|
||||
|
||||
actionPromise
|
||||
|
||||
@@ -16,11 +16,10 @@ import makeStyles from '@mui/styles/makeStyles';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { mutate } from 'swr';
|
||||
import client from 'util/client';
|
||||
import useLocalStorage from 'util/useLocalStorage';
|
||||
import { IManga, ISource } from 'typings';
|
||||
import { t as translate } from 'i18next';
|
||||
import makeToast from 'components/util/Toast';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
const useStyles = (inLibrary: boolean) =>
|
||||
makeStyles((theme: Theme) => ({
|
||||
@@ -136,9 +135,6 @@ function getValueOrUnknown(val: string) {
|
||||
const MangaDetails: React.FC<IProps> = ({ manga }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
|
||||
const [useCache] = useLocalStorage<boolean>('useCache', true);
|
||||
|
||||
const classes = useStyles(manga.inLibrary)();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -148,17 +144,13 @@ const MangaDetails: React.FC<IProps> = ({ manga }) => {
|
||||
}, [manga.source]);
|
||||
|
||||
const addToLibrary = () => {
|
||||
mutate(`/api/v1/manga/${manga.id}/?onlineFetch=false`, { ...manga, inLibrary: true }, { revalidate: false });
|
||||
client
|
||||
.get(`/api/v1/manga/${manga.id}/library/`)
|
||||
.then(() => mutate(`/api/v1/manga/${manga.id}/?onlineFetch=false`));
|
||||
mutate(`/api/v1/manga/${manga.id}`, { ...manga, inLibrary: true }, { revalidate: false });
|
||||
requestManager.addMangaToLibrary(manga.id).then(() => mutate(`/api/v1/manga/${manga.id}`));
|
||||
};
|
||||
|
||||
const removeFromLibrary = () => {
|
||||
mutate(`/api/v1/manga/${manga.id}/?onlineFetch=false`, { ...manga, inLibrary: false }, { revalidate: false });
|
||||
client
|
||||
.delete(`/api/v1/manga/${manga.id}/library/`)
|
||||
.then(() => mutate(`/api/v1/manga/${manga.id}/?onlineFetch=false`));
|
||||
mutate(`/api/v1/manga/${manga.id}`, { ...manga, inLibrary: false }, { revalidate: false });
|
||||
requestManager.removeMangaFromLibrary(manga.id).then(() => mutate(`/api/v1/manga/${manga.id}`));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -166,7 +158,7 @@ const MangaDetails: React.FC<IProps> = ({ manga }) => {
|
||||
<div className={classes.top}>
|
||||
<div className={classes.leftRight}>
|
||||
<div className={classes.leftSide}>
|
||||
<img src={`${serverAddress}${manga.thumbnailUrl}?useCache=${useCache}`} alt="Manga Thumbnail" />
|
||||
<img src={requestManager.getValidImgUrlFor(manga.thumbnailUrl)} alt="Manga Thumbnail" />
|
||||
</div>
|
||||
<div className={classes.rightSide}>
|
||||
<h1>{manga.title}</h1>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { mutate } from 'swr';
|
||||
import { fetcher } from 'util/client';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const useRefreshManga = (mangaId: string) => {
|
||||
@@ -17,14 +17,18 @@ export const useRefreshManga = (mangaId: string) => {
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setFetchingOnline(true);
|
||||
await Promise.all([
|
||||
fetcher(`/api/v1/manga/${mangaId}/?onlineFetch=true`).then((res) =>
|
||||
mutate(`/api/v1/manga/${mangaId}/?onlineFetch=false`, res, { revalidate: false }),
|
||||
),
|
||||
fetcher(`/api/v1/manga/${mangaId}/chapters?onlineFetch=true`).then((res) =>
|
||||
mutate(`/api/v1/manga/${mangaId}/chapters?onlineFetch=false`, res, {
|
||||
revalidate: false,
|
||||
}),
|
||||
),
|
||||
requestManager
|
||||
.getClient()
|
||||
.get(`/api/v1/manga/${mangaId}/?onlineFetch=true`)
|
||||
.then((res) => mutate(`/api/v1/manga/${mangaId}`, res.data, { revalidate: false })),
|
||||
requestManager
|
||||
.getClient()
|
||||
.get(`/api/v1/manga/${mangaId}/chapters?onlineFetch=true`)
|
||||
.then((res) =>
|
||||
mutate(`/api/v1/manga/${mangaId}/chapters`, res.data, {
|
||||
revalidate: false,
|
||||
}),
|
||||
),
|
||||
]).finally(() => setFetchingOnline(false));
|
||||
}, [mangaId]);
|
||||
|
||||
|
||||
@@ -15,9 +15,8 @@ import Dialog from '@mui/material/Dialog';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import FormGroup from '@mui/material/FormGroup';
|
||||
import client, { useQuery } from 'util/client';
|
||||
import { ICategory } from 'typings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
interface IProps {
|
||||
open: boolean;
|
||||
@@ -30,8 +29,8 @@ export default function CategorySelect(props: IProps) {
|
||||
|
||||
const { open, setOpen, mangaId } = props;
|
||||
|
||||
const { data: mangaCategoriesData, mutate } = useQuery<ICategory[]>(`/api/v1/manga/${mangaId}/category`);
|
||||
const { data: categoriesData } = useQuery<ICategory[]>('/api/v1/category');
|
||||
const { data: mangaCategoriesData, mutate } = requestManager.useGetMangaCategories(mangaId);
|
||||
const { data: categoriesData } = requestManager.useGetCategories();
|
||||
|
||||
const allCategories = useMemo(() => {
|
||||
const cats = [...(categoriesData ?? [])]; // make copy
|
||||
@@ -54,8 +53,10 @@ export default function CategorySelect(props: IProps) {
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>, categoryId: number) => {
|
||||
const { checked } = event.target as HTMLInputElement;
|
||||
|
||||
const method = checked ? client.get : client.delete;
|
||||
method(`/api/v1/manga/${mangaId}/category/${categoryId}`).then(() => mutate());
|
||||
(checked
|
||||
? requestManager.addMangaToCategory(mangaId, categoryId)
|
||||
: requestManager.removeMangaFromCategory(mangaId, categoryId)
|
||||
).then(() => mutate());
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import SpinnerImage from 'components/util/SpinnerImage';
|
||||
import useLocalStorage from 'util/useLocalStorage';
|
||||
import Box from '@mui/system/Box';
|
||||
import { IReaderSettings } from 'typings';
|
||||
|
||||
@@ -68,8 +67,6 @@ interface IProps {
|
||||
const Page = React.forwardRef((props: IProps, ref: any) => {
|
||||
const { src, index, onImageLoad, settings } = props;
|
||||
|
||||
const [useCache] = useLocalStorage<boolean>('useCache', true);
|
||||
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
const imgStyle = imageStyle(settings);
|
||||
@@ -77,7 +74,7 @@ const Page = React.forwardRef((props: IProps, ref: any) => {
|
||||
return (
|
||||
<Box ref={ref} sx={{ margin: 'auto' }}>
|
||||
<SpinnerImage
|
||||
src={`${src}?useCache=${useCache}`}
|
||||
src={src}
|
||||
onImageLoad={onImageLoad}
|
||||
alt={`Page #${index}`}
|
||||
imgRef={imgRef}
|
||||
|
||||
603
src/lib/RequestManager.ts
Normal file
603
src/lib/RequestManager.ts
Normal file
@@ -0,0 +1,603 @@
|
||||
/*
|
||||
* 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 { AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import useSWR, { SWRConfiguration, SWRResponse } from 'swr';
|
||||
import useSWRInfinite, { SWRInfiniteConfiguration, SWRInfiniteResponse } from 'swr/infinite';
|
||||
import {
|
||||
BackupValidationResult,
|
||||
BatchChaptersChange,
|
||||
IAbout,
|
||||
ICategory,
|
||||
IChapter,
|
||||
IExtension,
|
||||
IManga,
|
||||
IMangaChapter,
|
||||
IncludeInGlobalUpdate,
|
||||
ISource,
|
||||
ISourceFilters,
|
||||
IUpdateStatus,
|
||||
Metadata,
|
||||
PaginatedList,
|
||||
SourcePreferences,
|
||||
SourceSearchResult,
|
||||
UpdateCheck,
|
||||
} from 'typings';
|
||||
import storage from 'util/localStorage';
|
||||
import { HttpMethod as DefaultHttpMethod, IRestClient, RestClient } from 'lib/RestClient';
|
||||
|
||||
enum SWRHttpMethod {
|
||||
SWR_GET,
|
||||
SWR_GET_INFINITE,
|
||||
SWR_POST,
|
||||
}
|
||||
|
||||
type HttpMethodType = DefaultHttpMethod | SWRHttpMethod;
|
||||
const HttpMethod = { ...SWRHttpMethod, ...DefaultHttpMethod };
|
||||
|
||||
type RequestOption = { doOnlineFetch?: boolean };
|
||||
|
||||
type CustomSWROptions<Data> = {
|
||||
skipRequest?: boolean;
|
||||
getEndpoint?: (index: number, previousData: Data | null) => string | null;
|
||||
};
|
||||
|
||||
type SWROptions<Data = any, Error = any> = SWRConfiguration<Data, Error> & CustomSWROptions<Data>;
|
||||
type SWRInfiniteOptions<Data = any, Error = any> = SWRInfiniteConfiguration<Data, Error> & CustomSWROptions<Data>;
|
||||
|
||||
// the following endpoints have not been implemented:
|
||||
// - PUT /api/v1/manga/{mangaId}/chapter/{chapterIndex} - modify chapter # PATCH endpoint used instead
|
||||
// - POST /api/v1/backup/import - import backup # "import backup file" endpoint used instead
|
||||
// - POST /api/v1/backup/validate - validate backup # "validate backup file" endpoint used instead
|
||||
// - GET /api/v1/backup/export - export backup # no function needed, url gets called via link triggering the download
|
||||
export class RequestManager {
|
||||
private static readonly API_VERSION = '/api/v1/';
|
||||
|
||||
private readonly restClient: RestClient = new RestClient();
|
||||
|
||||
public getClient(): IRestClient {
|
||||
return this.restClient;
|
||||
}
|
||||
|
||||
public updateClient(config: AxiosRequestConfig): void {
|
||||
this.restClient.updateConfig(config);
|
||||
}
|
||||
|
||||
public getBaseUrl(): string {
|
||||
return this.restClient.getClient().defaults.baseURL!;
|
||||
}
|
||||
|
||||
public getWebSocketBaseUrl(): string {
|
||||
return this.getBaseUrl().replace('http', 'ws');
|
||||
}
|
||||
|
||||
public getValidWebSocketUrl(path: string, apiVersion = RequestManager.API_VERSION): string {
|
||||
return `${this.getWebSocketBaseUrl()}${apiVersion}${path}`;
|
||||
}
|
||||
|
||||
public getUpdateWebSocket(): WebSocket {
|
||||
return new WebSocket(this.getValidWebSocketUrl('update'));
|
||||
}
|
||||
|
||||
public getDownloadWebSocket(): WebSocket {
|
||||
return new WebSocket(this.getValidWebSocketUrl('downloads'));
|
||||
}
|
||||
|
||||
public getValidUrlFor(endpoint: string, apiVersion: string = RequestManager.API_VERSION): string {
|
||||
return `${this.getBaseUrl()}${apiVersion}${endpoint}`;
|
||||
}
|
||||
|
||||
public getValidImgUrlFor(imageUrl: string, apiVersion: string = ''): string {
|
||||
const useCache = storage.getItem('useCache', true);
|
||||
const useCacheQuery = `?useCache=${useCache}`;
|
||||
// server provided image urls already contain the api version
|
||||
return `${this.getValidUrlFor(imageUrl, apiVersion)}${useCacheQuery}`;
|
||||
}
|
||||
|
||||
private useSwr<
|
||||
Data = any,
|
||||
ErrorResponse = any,
|
||||
OptionsSWR extends SWROptions<Data, ErrorResponse> = SWROptions<Data, ErrorResponse>,
|
||||
>(
|
||||
url: string,
|
||||
httpMethod: DefaultHttpMethod,
|
||||
{
|
||||
data,
|
||||
axiosOptions,
|
||||
swrOptions,
|
||||
}: {
|
||||
data?: Data;
|
||||
axiosOptions?: AxiosRequestConfig;
|
||||
swrOptions?: OptionsSWR;
|
||||
} = {},
|
||||
): SWRResponse<Data, ErrorResponse> {
|
||||
const { skipRequest, ...swrConfig } = swrOptions ?? {};
|
||||
|
||||
// in case "null" gets passed as the url, SWR won't do the request
|
||||
return useSWR(skipRequest ? null : url, {
|
||||
fetcher: (path: string) => this.restClient.fetcher(path, { data, httpMethod, config: axiosOptions }),
|
||||
...swrConfig,
|
||||
});
|
||||
}
|
||||
|
||||
public useSwrInfinite<Data = any, ErrorResponse = any>(
|
||||
getEndpoint: Required<CustomSWROptions<Data>>['getEndpoint'],
|
||||
{
|
||||
axiosOptions,
|
||||
swrOptions,
|
||||
}: { axiosOptions?: AxiosRequestConfig; swrOptions?: SWRInfiniteConfiguration<Data, ErrorResponse> } = {},
|
||||
): SWRInfiniteResponse<Data, ErrorResponse> {
|
||||
// useSWRInfinite will (by default) revalidate the first page, to check if the other pages have to be revalidated as well
|
||||
const result = useSWRInfinite<Data, ErrorResponse>(
|
||||
(index, previousData) => {
|
||||
const pageEndpoint = getEndpoint(index, previousData);
|
||||
return pageEndpoint !== null ? this.getValidUrlFor(pageEndpoint) : null;
|
||||
},
|
||||
{
|
||||
fetcher: (path: string) =>
|
||||
this.restClient.fetcher(path, { httpMethod: HttpMethod.GET, config: axiosOptions }),
|
||||
...swrOptions,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
// SWR "isLoading" state is only updated for the first load, for every subsequent load it's "false"
|
||||
isLoading:
|
||||
result.isLoading ||
|
||||
// check if more data is being loaded
|
||||
(result.size > 0 && !!result.data && typeof result.data[result.size - 1] === 'undefined'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the actual server request.
|
||||
*
|
||||
* In case {@link HttpMethod.GET_SWR} gets passed, the "useSWR" hook gets called.
|
||||
* In case {@link HttpMethod.GET_SWR_INFINITE} gets passed, the "useSWRInfinite" hook gets called.
|
||||
* In that case "getEndpoint" has to be passed, which gets used over "endpoint"
|
||||
*
|
||||
* Pass "skipRequest" to make SWR skip sending the request to the server.
|
||||
* Only works for none "infinite" requests, for "infinite" requests the "getEndpoint" function can return "null".
|
||||
* In case "formData" is passed, "data" gets ignored.
|
||||
*/
|
||||
private doRequest<
|
||||
Result extends Promise<AxiosResponse> | SWRResponse | SWRInfiniteResponse,
|
||||
OptionsSWR extends SWROptions | SWRInfiniteOptions,
|
||||
>(
|
||||
httpMethod: HttpMethodType,
|
||||
endpoint: string,
|
||||
{
|
||||
apiVersion = RequestManager.API_VERSION,
|
||||
data: dataToSend,
|
||||
formData,
|
||||
axiosOptions,
|
||||
swrOptions,
|
||||
}: {
|
||||
apiVersion?: string;
|
||||
data?: any;
|
||||
formData?: { [key: string]: any };
|
||||
axiosOptions?: AxiosRequestConfig;
|
||||
swrOptions?: OptionsSWR;
|
||||
} = {},
|
||||
): Result {
|
||||
const url = `${apiVersion}${endpoint}`;
|
||||
|
||||
let data = dataToSend;
|
||||
if (formData) {
|
||||
data = new FormData();
|
||||
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
if (value !== undefined) data.append(key, `${value}`);
|
||||
});
|
||||
}
|
||||
|
||||
switch (httpMethod) {
|
||||
case HttpMethod.SWR_GET:
|
||||
return this.useSwr(url, HttpMethod.GET, { axiosOptions, swrOptions }) as Result;
|
||||
case HttpMethod.SWR_GET_INFINITE:
|
||||
// throw TypeError in case options aren't correctly passed
|
||||
return this.useSwrInfinite(swrOptions!.getEndpoint!, { axiosOptions, swrOptions }) as Result;
|
||||
case HttpMethod.SWR_POST:
|
||||
return this.useSwr(url, HttpMethod.POST, { data, axiosOptions, swrOptions }) as Result;
|
||||
default:
|
||||
return this.restClient.fetcher(url, {
|
||||
data,
|
||||
httpMethod,
|
||||
config: axiosOptions,
|
||||
checkResponseIsJson: false,
|
||||
}) as Result;
|
||||
}
|
||||
}
|
||||
|
||||
public useGetGlobalMeta(swrOptions?: SWROptions<Metadata>): SWRResponse<Metadata> {
|
||||
return this.doRequest(HttpMethod.SWR_GET, 'meta', { swrOptions });
|
||||
}
|
||||
|
||||
public setGlobalMetadata(key: string, value: any): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.PATCH, 'meta', { formData: { key, value } });
|
||||
}
|
||||
|
||||
public useGetAbout(swrOptions?: SWROptions<IAbout>): SWRResponse<IAbout> {
|
||||
return this.doRequest(HttpMethod.SWR_GET, 'settings/about', { swrOptions });
|
||||
}
|
||||
|
||||
public useCheckForUpdate(swrOptions?: SWROptions<UpdateCheck[]>): SWRResponse<UpdateCheck[]> {
|
||||
return this.doRequest(HttpMethod.SWR_GET, 'settings/check-update', { swrOptions });
|
||||
}
|
||||
|
||||
public useGetExtensionList(swrOptions?: SWROptions<IExtension[]>): SWRResponse<IExtension[]> {
|
||||
return this.doRequest(HttpMethod.SWR_GET, 'extension/list', { swrOptions });
|
||||
}
|
||||
|
||||
public installExtension(extension: string | File): Promise<AxiosResponse> {
|
||||
if (typeof extension === 'string') {
|
||||
return this.doRequest(HttpMethod.GET, `extension/install/${extension}`);
|
||||
}
|
||||
|
||||
return this.doRequest(HttpMethod.POST, `extension/install`, { formData: { file: extension } });
|
||||
}
|
||||
|
||||
public updateExtension(extension: string): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.GET, `extension/update/${extension}`);
|
||||
}
|
||||
|
||||
public uninstallExtension(extension: string): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.GET, `extension/uninstall/${extension}`);
|
||||
}
|
||||
|
||||
public getExtensionIconUrl(extension: string): string {
|
||||
return this.getValidImgUrlFor(`extension/icon/${extension}`);
|
||||
}
|
||||
|
||||
public useGetSourceList(swrOptions?: SWROptions<ISource[]>): SWRResponse<ISource[]> {
|
||||
return this.doRequest(HttpMethod.SWR_GET, 'source/list', { swrOptions });
|
||||
}
|
||||
|
||||
public useGetSource(sourceId: string, swrOptions?: SWROptions<ISource>): SWRResponse<ISource> {
|
||||
return this.doRequest(HttpMethod.SWR_GET, `source/${sourceId}`, { swrOptions });
|
||||
}
|
||||
|
||||
public useGetSourcePopularMangas(
|
||||
sourceId: string,
|
||||
extension: string,
|
||||
initialPages?: number,
|
||||
swrOptions?: SWRInfiniteOptions<PaginatedList<IManga>>,
|
||||
): SWRInfiniteResponse<PaginatedList<IManga>> {
|
||||
return this.doRequest(SWRHttpMethod.SWR_GET_INFINITE, '', {
|
||||
swrOptions: {
|
||||
getEndpoint: (page, previousData) =>
|
||||
previousData?.hasNextPage ? `source/${sourceId}/popular/${extension}?pageNum=${page}` : null,
|
||||
initialSize: initialPages,
|
||||
...swrOptions,
|
||||
} as typeof swrOptions,
|
||||
});
|
||||
}
|
||||
|
||||
public useGetSourceLatestMangas(
|
||||
sourceId: string,
|
||||
extension: string,
|
||||
initialPages?: number,
|
||||
swrOptions?: SWRInfiniteOptions<PaginatedList<IManga>>,
|
||||
): SWRInfiniteResponse<PaginatedList<IManga>> {
|
||||
return this.doRequest(SWRHttpMethod.SWR_GET_INFINITE, '', {
|
||||
swrOptions: {
|
||||
getEndpoint: (page, previousData) =>
|
||||
previousData?.hasNextPage ? `source/${sourceId}/latest/${extension}?pageNum=${page}` : null,
|
||||
initialSize: initialPages,
|
||||
...swrOptions,
|
||||
} as typeof swrOptions,
|
||||
});
|
||||
}
|
||||
|
||||
public useGetSourcePreferences(
|
||||
sourceId: string,
|
||||
swrOptions?: SWROptions<SourcePreferences[]>,
|
||||
): SWRResponse<SourcePreferences[]> {
|
||||
return this.doRequest(HttpMethod.SWR_GET, `source/${sourceId}/preferences`, { swrOptions });
|
||||
}
|
||||
|
||||
public setSourcePreferences(sourceId: string, position: number, value: string): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.POST, `source/${sourceId}/preferences`, { data: { position, value } });
|
||||
}
|
||||
|
||||
public useGetSourceFilters(
|
||||
sourceId: string,
|
||||
reset?: boolean,
|
||||
swrOptions?: SWROptions<ISourceFilters[]>,
|
||||
): SWRResponse<ISourceFilters[]> {
|
||||
return this.doRequest(HttpMethod.SWR_GET, `source/${sourceId}/filters`, { swrOptions });
|
||||
}
|
||||
|
||||
public setSourceFilters(sourceId: string, filters: { position: number; state: string }[]): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.POST, `source/${sourceId}/filters`, { data: { filters } });
|
||||
}
|
||||
|
||||
public resetSourceFilters(sourceId: string): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.GET, `source/${sourceId}/filters?reset=true`);
|
||||
}
|
||||
|
||||
public useSourceSearch(
|
||||
sourceId: string,
|
||||
searchTerm: string,
|
||||
initialPages?: number,
|
||||
swrOptions?: SWRInfiniteOptions<SourceSearchResult>,
|
||||
): SWRInfiniteResponse<SourceSearchResult> {
|
||||
return this.doRequest(HttpMethod.SWR_GET_INFINITE, '', {
|
||||
swrOptions: {
|
||||
getEndpoint: (page, previousData) =>
|
||||
previousData?.hasNextPage
|
||||
? `source/${sourceId}/search?searchTerm=${searchTerm}&pageNum=${page}`
|
||||
: null,
|
||||
initialSize: initialPages,
|
||||
...swrOptions,
|
||||
} as typeof swrOptions,
|
||||
});
|
||||
}
|
||||
|
||||
public useSourceQuickSearch(
|
||||
sourceId: string,
|
||||
searchTerm: string,
|
||||
filters: { position: number; state: string }[],
|
||||
initialPages?: number,
|
||||
swrOptions?: SWRInfiniteOptions<SourceSearchResult>,
|
||||
): SWRInfiniteResponse<SourceSearchResult> {
|
||||
return this.doRequest(HttpMethod.SWR_GET_INFINITE, '', {
|
||||
data: filters,
|
||||
swrOptions: {
|
||||
getEndpoint: (page, previousData) =>
|
||||
previousData?.hasNextPage
|
||||
? `source/${sourceId}/quick-search?searchTerm=${searchTerm}&pageNum=${page}`
|
||||
: null,
|
||||
initialSize: initialPages,
|
||||
...swrOptions,
|
||||
} as typeof swrOptions,
|
||||
});
|
||||
}
|
||||
|
||||
public useGetManga(
|
||||
mangaId: number | string,
|
||||
{ doOnlineFetch, ...swrOptions }: SWROptions<IManga> & RequestOption = {},
|
||||
): SWRResponse<IManga> {
|
||||
const onlineFetch = doOnlineFetch ? '?onlineFetch=true' : '';
|
||||
return this.doRequest(HttpMethod.SWR_GET, `manga/${mangaId}${onlineFetch}`, {
|
||||
swrOptions,
|
||||
});
|
||||
}
|
||||
|
||||
public useGetFullManga(
|
||||
mangaId: number | string,
|
||||
{ doOnlineFetch, ...swrOptions }: SWROptions<IManga> & RequestOption = {},
|
||||
): SWRResponse<IManga> {
|
||||
const onlineFetch = doOnlineFetch ? '?onlineFetch=true' : '';
|
||||
return this.doRequest(HttpMethod.SWR_GET, `manga/${mangaId}/full${onlineFetch}`, {
|
||||
swrOptions,
|
||||
});
|
||||
}
|
||||
|
||||
public getMangaThumbnailUrl(mangaId: number): string {
|
||||
return this.getValidImgUrlFor(`manga/${mangaId}/thumbnail`);
|
||||
}
|
||||
|
||||
public useGetMangaCategories(mangaId: number, swrOptions?: SWROptions<ICategory[]>): SWRResponse<ICategory[]> {
|
||||
return this.doRequest(HttpMethod.SWR_GET, `manga/${mangaId}/category`, { swrOptions });
|
||||
}
|
||||
|
||||
public addMangaToCategory(mangaId: number, categoryId: number): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.GET, `manga/${mangaId}/category/${categoryId}`);
|
||||
}
|
||||
|
||||
public removeMangaFromCategory(mangaId: number, categoryId: number): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.DELETE, `manga/${mangaId}/category/${categoryId}`);
|
||||
}
|
||||
|
||||
public addMangaToLibrary(mangaId: number | string): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.GET, `manga/${mangaId}/library`);
|
||||
}
|
||||
|
||||
public removeMangaFromLibrary(mangaId: number | string): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.DELETE, `manga/${mangaId}/library`);
|
||||
}
|
||||
|
||||
public setMangaMeta(mangaId: number, key: string, value: any): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.POST, `manga/${mangaId}/meta`, { formData: { key, value } });
|
||||
}
|
||||
|
||||
public useGetMangaChapters(
|
||||
mangaId: number | string,
|
||||
{ doOnlineFetch, ...swrOptions }: SWROptions<IChapter[]> & RequestOption = {},
|
||||
): SWRResponse<IChapter[]> {
|
||||
const onlineFetch = doOnlineFetch ? '?onlineFetch=true' : '';
|
||||
return this.doRequest(HttpMethod.SWR_GET, `manga/${mangaId}/chapters${onlineFetch}`, {
|
||||
swrOptions,
|
||||
});
|
||||
}
|
||||
|
||||
public updateMangaChapters(
|
||||
mangaId: number | string,
|
||||
{
|
||||
chapterIds,
|
||||
chapterIndexes,
|
||||
change,
|
||||
}: (
|
||||
| { chapterIds?: number[]; chapterIndexes: number[] }
|
||||
| { chapterIds: number[]; chapterIndexes?: number[] }
|
||||
) & { change: BatchChaptersChange },
|
||||
): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.POST, `manga/${mangaId}/chapter/batch`, {
|
||||
data: {
|
||||
chapterIds,
|
||||
chapterIndexes,
|
||||
change,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public useGetChapter(
|
||||
mangaId: number | string,
|
||||
chapterIndex: number | string,
|
||||
swrOptions?: SWROptions<IChapter>,
|
||||
): SWRResponse<IChapter> {
|
||||
return this.doRequest(HttpMethod.SWR_GET, `manga/${mangaId}/chapter/${chapterIndex}`, {
|
||||
swrOptions,
|
||||
});
|
||||
}
|
||||
|
||||
public deleteDownloadedChapter(mangaId: number | string, chapterIndex: number | string): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.DELETE, `manga/${mangaId}/chapter/${chapterIndex}`);
|
||||
}
|
||||
|
||||
public updateChapter(
|
||||
mangaId: number | string,
|
||||
chapterIndex: number | string,
|
||||
change: { read?: boolean; bookmarked?: boolean; markPrevRead?: boolean; lastPageRead?: number } = {},
|
||||
): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.PATCH, `manga/${mangaId}/chapter/${chapterIndex}`, { formData: change });
|
||||
}
|
||||
|
||||
public setChapterMeta(
|
||||
mangaId: number | string,
|
||||
chapterIndex: number | string,
|
||||
key: string,
|
||||
value: any,
|
||||
): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.PATCH, `manga/${mangaId}/chapter/${chapterIndex}/meta`, {
|
||||
formData: { key, value },
|
||||
});
|
||||
}
|
||||
|
||||
public getChapterPageUrl(mangaId: number | string, chapterIndex: number | string, page: number): string {
|
||||
return this.getValidImgUrlFor(
|
||||
`manga/${mangaId}/chapter/${chapterIndex}/page/${page}`,
|
||||
RequestManager.API_VERSION,
|
||||
);
|
||||
}
|
||||
|
||||
public updateChapters(chapterIds: number[], change: BatchChaptersChange): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.POST, `chapter/batch`, { data: { chapterIds, change } });
|
||||
}
|
||||
|
||||
public useGetCategories(swrOptions?: SWROptions<ICategory[]>): SWRResponse<ICategory[]> {
|
||||
return this.doRequest(HttpMethod.SWR_GET, `category`, { swrOptions });
|
||||
}
|
||||
|
||||
public createCategory(name: string): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.POST, `category`, { formData: { name } });
|
||||
}
|
||||
|
||||
public reorderCategory(currentPosition: number, newPosition: number): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.PATCH, `category/reorder`, {
|
||||
formData: { from: currentPosition, to: newPosition },
|
||||
});
|
||||
}
|
||||
|
||||
public useGetCategoryMangas(categoryId: number, swrOptions?: SWROptions<IManga[]>): SWRResponse<IManga[]> {
|
||||
return this.doRequest(HttpMethod.SWR_GET, `category/${categoryId}`, { swrOptions });
|
||||
}
|
||||
|
||||
public deleteCategory(categoryId: number): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.DELETE, `category/${categoryId}`);
|
||||
}
|
||||
|
||||
public updateCategory(
|
||||
categoryId: number,
|
||||
change: { name?: string; default?: boolean; includeInUpdate?: IncludeInGlobalUpdate } = {},
|
||||
): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.PATCH, `category/${categoryId}`, { formData: change });
|
||||
}
|
||||
|
||||
public setCategoryMeta(categoryId: number, key: string, value: any): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.PATCH, `category/${categoryId}`, { formData: { key, value } });
|
||||
}
|
||||
|
||||
public restoreBackupFile(file: File): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.POST, 'backup/import/file', { formData: { 'backup.proto.gz': file } });
|
||||
}
|
||||
|
||||
public useValidateBackupFile(
|
||||
file: File,
|
||||
swrOptions?: SWROptions<BackupValidationResult>,
|
||||
): SWRResponse<BackupValidationResult> {
|
||||
return this.doRequest(HttpMethod.SWR_POST, 'backup/validate/file', {
|
||||
formData: { 'backup.proto.gz': file },
|
||||
swrOptions,
|
||||
});
|
||||
}
|
||||
|
||||
public getExportBackupUrl(): string {
|
||||
return this.getValidUrlFor('backup/export/file');
|
||||
}
|
||||
|
||||
public startDownloads(): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.GET, 'downloads/start');
|
||||
}
|
||||
|
||||
public stopDownloads(): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.GET, 'downloads/stop');
|
||||
}
|
||||
|
||||
public clearDownloads(): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.GET, 'downloads/clear');
|
||||
}
|
||||
|
||||
public addChapterToDownloadQueue(mangaId: number | string, chapterIndex: number | string): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.GET, `download/${mangaId}/chapter/${chapterIndex}`);
|
||||
}
|
||||
|
||||
public removeChapterFromDownloadQueue(
|
||||
mangaId: number | string,
|
||||
chapterIndex: number | string,
|
||||
): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.DELETE, `download/${mangaId}/chapter/${chapterIndex}`);
|
||||
}
|
||||
|
||||
public reorderChapterInDownloadQueue(
|
||||
mangaId: number | string,
|
||||
chapterIndex: number | string,
|
||||
position: number,
|
||||
): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.PATCH, `download/${mangaId}/chapter/${chapterIndex}/reorder/${position}`);
|
||||
}
|
||||
|
||||
public addChaptersToDownloadQueue(chapterIds: number[]): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.POST, 'download/batch', { data: { chapterIds } });
|
||||
}
|
||||
|
||||
public removeChaptersFromDownloadQueue(chapterIds: number[]): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.DELETE, 'download/batch', { data: { chapterIds } });
|
||||
}
|
||||
|
||||
public useGetRecentlyUpdatedChapters(
|
||||
initialPages?: number,
|
||||
swrOptions?: SWRInfiniteOptions<PaginatedList<IMangaChapter>>,
|
||||
): SWRInfiniteResponse<PaginatedList<IMangaChapter>> {
|
||||
return this.doRequest(HttpMethod.SWR_GET_INFINITE, '', {
|
||||
swrOptions: {
|
||||
getEndpoint: (page, previousData) =>
|
||||
previousData?.hasNextPage ?? true ? `update/recentChapters/${page}` : null,
|
||||
initialSize: initialPages,
|
||||
...swrOptions,
|
||||
} as typeof swrOptions,
|
||||
});
|
||||
}
|
||||
|
||||
public startGlobalUpdate(categoryId?: number): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.POST, 'update/fetch', { formData: { categoryId } });
|
||||
}
|
||||
|
||||
public resetGlobalUpdate(): Promise<AxiosResponse> {
|
||||
return this.doRequest(HttpMethod.POST, 'update/reset');
|
||||
}
|
||||
|
||||
public useGetGlobalUpdateSummary(swrOptions?: SWROptions<IUpdateStatus>): SWRResponse<IUpdateStatus> {
|
||||
return this.doRequest(HttpMethod.SWR_GET, 'update/summary', { swrOptions });
|
||||
}
|
||||
}
|
||||
|
||||
const requestManager = new RequestManager();
|
||||
export default requestManager;
|
||||
125
src/lib/RestClient.ts
Normal file
125
src/lib/RestClient.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* 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 axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import storage from 'util/localStorage';
|
||||
|
||||
export enum HttpMethod {
|
||||
GET = 'get',
|
||||
POST = 'post',
|
||||
PATCH = 'patch',
|
||||
DELETE = 'delete',
|
||||
}
|
||||
|
||||
type SimpleRestResponse<Data = any> = {
|
||||
data: Data;
|
||||
};
|
||||
|
||||
export interface IRestClient {
|
||||
get<Data = any, Response = SimpleRestResponse<Data>>(url: string): Promise<Response>;
|
||||
delete<Data = any, Response = SimpleRestResponse<Data>>(url: string): Promise<Response>;
|
||||
post<Data = any, Response = SimpleRestResponse<Data>>(url: string, data?: any): Promise<Response>;
|
||||
put<Data = any, Response = SimpleRestResponse<Data>>(url: string, data?: any): Promise<Response>;
|
||||
patch<Data = any, Response = SimpleRestResponse<Data>>(url: string, data?: any): Promise<Response>;
|
||||
}
|
||||
|
||||
export class RestClient implements IRestClient {
|
||||
protected client!: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.createClient();
|
||||
}
|
||||
|
||||
public readonly fetcher = async <Data = any>(
|
||||
url: string,
|
||||
{
|
||||
data,
|
||||
httpMethod = HttpMethod.GET,
|
||||
config,
|
||||
checkResponseIsJson = true,
|
||||
}: {
|
||||
data?: any;
|
||||
httpMethod?: HttpMethod;
|
||||
config?: AxiosRequestConfig;
|
||||
checkResponseIsJson?: boolean;
|
||||
} = {},
|
||||
): Promise<Data> => {
|
||||
let result: AxiosResponse<Data>;
|
||||
|
||||
switch (httpMethod) {
|
||||
case HttpMethod.GET:
|
||||
result = await this.client[httpMethod](url, config);
|
||||
break;
|
||||
case HttpMethod.POST:
|
||||
case HttpMethod.PATCH:
|
||||
case HttpMethod.DELETE:
|
||||
result = await this.client[httpMethod](url, data, config);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unexpected HttpMethod "${httpMethod}"`);
|
||||
}
|
||||
|
||||
if (result.status !== 200) {
|
||||
throw new Error(result.statusText);
|
||||
}
|
||||
|
||||
if (checkResponseIsJson && result.headers['content-type'] !== 'application/json') {
|
||||
throw new Error('Response is not json');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
private createClient(): void {
|
||||
const { hostname, port, protocol } = window.location;
|
||||
|
||||
// if port is 3000 it's probably running from webpack development server
|
||||
const inferredPort = port === '3000' ? '4567' : port;
|
||||
const baseURL = storage.getItem('serverBaseURL', `${protocol}//${hostname}:${inferredPort}`);
|
||||
|
||||
this.client = axios.create({
|
||||
// baseURL must not have trailing slash
|
||||
baseURL,
|
||||
});
|
||||
|
||||
this.client.interceptors.request.use((config) => {
|
||||
if (config.data instanceof FormData) {
|
||||
Object.assign(config.headers, { 'Content-Type': 'multipart/form-data' });
|
||||
}
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
public updateConfig(config: AxiosRequestConfig): void {
|
||||
this.client.defaults = { ...this.client.defaults, ...config };
|
||||
}
|
||||
|
||||
public getClient(): AxiosInstance {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
get get() {
|
||||
return this.client.get;
|
||||
}
|
||||
|
||||
get post() {
|
||||
return this.client.post;
|
||||
}
|
||||
|
||||
get put() {
|
||||
return this.client.put;
|
||||
}
|
||||
|
||||
get patch() {
|
||||
return this.client.patch;
|
||||
}
|
||||
|
||||
get delete() {
|
||||
return this.client.delete;
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import NavbarContext from 'components/context/NavbarContext';
|
||||
import EmptyView from 'components/util/EmptyView';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
|
||||
import client from 'util/client';
|
||||
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Box } from '@mui/system';
|
||||
@@ -28,6 +27,7 @@ import { BACK } from 'util/useBackTo';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IChapter, IQueue } from 'typings';
|
||||
import makeToast from 'components/util/Toast';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
const initialQueue = {
|
||||
status: 'Stopped',
|
||||
@@ -37,16 +37,16 @@ const initialQueue = {
|
||||
const DownloadQueue: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: queueState } = useSubscription<IQueue>('/api/v1/downloads');
|
||||
const { data: queueState } = useSubscription<IQueue>('downloads');
|
||||
const { queue, status } = queueState ?? initialQueue;
|
||||
|
||||
const { setTitle, setAction } = useContext(NavbarContext);
|
||||
|
||||
const toggleQueueStatus = () => {
|
||||
if (status === 'Stopped') {
|
||||
client.get('/api/v1/downloads/start');
|
||||
requestManager.startDownloads();
|
||||
} else {
|
||||
client.get('/api/v1/downloads/stop');
|
||||
requestManager.stopDownloads();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -67,15 +67,15 @@ const DownloadQueue: React.FC = () => {
|
||||
try {
|
||||
if (isRunning) {
|
||||
// required to stop before deleting otherwise the download kept going. Server issue?
|
||||
await client.get('/api/v1/downloads/stop');
|
||||
await requestManager.stopDownloads();
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
// remove from download queue
|
||||
client.delete(`/api/v1/download/${chapter.mangaId}/chapter/${chapter.index}`),
|
||||
requestManager.removeChapterFromDownloadQueue(chapter.mangaId, chapter.index),
|
||||
// delete partial download, should be handle server side?
|
||||
// bug: The folder and the last image downloaded are not deleted
|
||||
client.delete(`/api/v1/manga/${chapter.mangaId}/chapter/${chapter.index}`),
|
||||
requestManager.deleteDownloadedChapter(chapter.mangaId, chapter.index),
|
||||
]);
|
||||
} catch (error) {
|
||||
makeToast(t('download.queue.error.label.failed_to_remove'), 'error');
|
||||
@@ -85,7 +85,7 @@ const DownloadQueue: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
client.get('/api/v1/downloads/start').catch(() => {});
|
||||
requestManager.startDownloads().catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,7 +12,6 @@ import IconButton from '@mui/material/IconButton';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import ExtensionCard from 'components/ExtensionCard';
|
||||
import NavbarContext from 'components/context/NavbarContext';
|
||||
import client, { useQuery } from 'util/client';
|
||||
import useLocalStorage from 'util/useLocalStorage';
|
||||
import LangSelect from 'components/navbar/action/LangSelect';
|
||||
import { extensionDefaultLangs, DefaultLanguage, langSortCmp } from 'util/language';
|
||||
@@ -31,6 +30,7 @@ import {
|
||||
isExtensionStateOrLanguage,
|
||||
translateExtensionLanguage,
|
||||
} from 'screens/util/Extensions';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
const LANGUAGE = 0;
|
||||
const EXTENSIONS = 1;
|
||||
@@ -102,7 +102,7 @@ export default function MangaExtensions() {
|
||||
);
|
||||
}, [t, shownLangs]);
|
||||
|
||||
const { data: allExtensions, mutate, isLoading } = useQuery<IExtension[]>('/api/v1/extension/list');
|
||||
const { data: allExtensions, mutate, isLoading } = requestManager.useGetExtensionList();
|
||||
|
||||
const filteredExtensions = useMemo(
|
||||
() =>
|
||||
@@ -128,18 +128,13 @@ export default function MangaExtensions() {
|
||||
|
||||
const submitExternalExtension = (file: File) => {
|
||||
if (file.name.toLowerCase().endsWith('apk')) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
|
||||
makeToast(t('extension.label.installing_file'), 'info');
|
||||
client
|
||||
.post('/api/v1/extension/install', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
requestManager
|
||||
.installExtension(file)
|
||||
.then(() => {
|
||||
makeToast(t('extension.label.installed_successfully'), 'success');
|
||||
mutate();
|
||||
|
||||
@@ -16,12 +16,11 @@ import LibraryToolbarMenu from 'components/library/LibraryToolbarMenu';
|
||||
import LibraryMangaGrid from 'components/library/LibraryMangaGrid';
|
||||
import AppbarSearch from 'components/util/AppbarSearch';
|
||||
import { useQueryParam, NumberParam } from 'use-query-params';
|
||||
import { useQuery } from 'util/client';
|
||||
import UpdateChecker from 'components/library/UpdateChecker';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ICategory, IManga } from 'typings';
|
||||
import { styled } from '@mui/system';
|
||||
import { useLibraryOptionsContext } from 'components/context/LibraryOptionsContext';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
const TitleWithSizeTag = styled('span')({
|
||||
display: 'flex',
|
||||
@@ -37,7 +36,7 @@ export default function Library() {
|
||||
|
||||
const { options } = useLibraryOptionsContext();
|
||||
const [lastLibraryUpdate, setLastLibraryUpdate] = useState(Date.now());
|
||||
const { data: tabsData, error: tabsError, isLoading } = useQuery<ICategory[]>('/api/v1/category');
|
||||
const { data: tabsData, error: tabsError, isLoading } = requestManager.useGetCategories();
|
||||
const tabs = tabsData ?? [];
|
||||
const librarySize = useMemo(() => tabs.map((tab) => tab.size).reduce((prev, curr) => prev + curr, 0), [tabs]);
|
||||
|
||||
@@ -48,7 +47,7 @@ export default function Library() {
|
||||
data: mangaData,
|
||||
error: mangaError,
|
||||
isLoading: mangaLoading,
|
||||
} = useQuery<IManga[]>(activeTab ? `/api/v1/category/${activeTab?.id}` : null);
|
||||
} = requestManager.useGetCategoryMangas(activeTab?.id, { skipRequest: !activeTab });
|
||||
const mangas = mangaData ?? [];
|
||||
|
||||
const { setTitle, setAction } = useContext(NavbarContext);
|
||||
|
||||
@@ -20,8 +20,7 @@ import LoadingPlaceholder from 'components/util/LoadingPlaceholder';
|
||||
import React, { useContext, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from 'util/client';
|
||||
import { IManga } from 'typings';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
const AUTOFETCH_AGE = 60 * 60 * 24; // 24 hours
|
||||
|
||||
@@ -32,13 +31,7 @@ const Manga: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const autofetchedRef = useRef(false);
|
||||
|
||||
const {
|
||||
data: manga,
|
||||
error,
|
||||
isLoading,
|
||||
isValidating,
|
||||
mutate,
|
||||
} = useQuery<IManga>(`/api/v1/manga/${id}/?onlineFetch=false`);
|
||||
const { data: manga, error, isLoading, isValidating, mutate } = requestManager.useGetManga(id);
|
||||
|
||||
const [refresh, { loading: refreshing }] = useRefreshManga(id);
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ import DoublePagedPager from 'components/reader/pager/DoublePagedPager';
|
||||
import VerticalPager from 'components/reader/pager/VerticalPager';
|
||||
import ReaderNavBar from 'components/navbar/ReaderNavBar';
|
||||
import NavbarContext from 'components/context/NavbarContext';
|
||||
import client from 'util/client';
|
||||
import useLocalStorage from 'util/useLocalStorage';
|
||||
import { Box } from '@mui/system';
|
||||
import { requestUpdateMangaMetadata } from 'util/metadata';
|
||||
import {
|
||||
@@ -26,21 +24,16 @@ import {
|
||||
useDefaultReaderSettings,
|
||||
} from 'util/readerSettings';
|
||||
import makeToast from 'components/util/Toast';
|
||||
import {
|
||||
ChapterOffset,
|
||||
IChapter,
|
||||
IManga,
|
||||
IMangaCard,
|
||||
IPartialChapter,
|
||||
IReaderSettings,
|
||||
ReaderType,
|
||||
TranslationKey,
|
||||
} from 'typings';
|
||||
import { ChapterOffset, IChapter, IManga, IMangaCard, IReaderSettings, ReaderType, TranslationKey } from 'typings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
const isDupChapter = async (chapterIndex: number, currentChapter: IChapter) => {
|
||||
const nextChapter = (await client.get<IChapter>(`/api/v1/manga/${currentChapter.mangaId}/chapter/${chapterIndex}`))
|
||||
.data;
|
||||
const nextChapter = (
|
||||
await requestManager
|
||||
.getClient()
|
||||
.get<IChapter>(`/api/v1/manga/${currentChapter.mangaId}/chapter/${chapterIndex}`)
|
||||
).data;
|
||||
|
||||
return nextChapter.chapterNumber === currentChapter.chapterNumber;
|
||||
};
|
||||
@@ -102,18 +95,22 @@ export default function Reader() {
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
|
||||
const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
|
||||
|
||||
const { chapterIndex, mangaId } = useParams<{ chapterIndex: string; mangaId: string }>();
|
||||
const [manga, setManga] = useState<IMangaCard | IManga>({
|
||||
id: +mangaId,
|
||||
title: '',
|
||||
thumbnailUrl: '',
|
||||
genre: [],
|
||||
inLibraryAt: 0,
|
||||
lastReadAt: 0,
|
||||
});
|
||||
const [chapter, setChapter] = useState<IChapter | IPartialChapter>(initialChapter());
|
||||
const {
|
||||
data: manga = {
|
||||
id: +mangaId,
|
||||
title: '',
|
||||
thumbnailUrl: '',
|
||||
genre: [],
|
||||
inLibraryAt: 0,
|
||||
lastReadAt: 0,
|
||||
} as IMangaCard | IManga,
|
||||
isLoading: isMangaLoading,
|
||||
} = requestManager.useGetManga(mangaId);
|
||||
const { data: chapter = initialChapter(), isLoading: isChapterLoading } = requestManager.useGetChapter(
|
||||
mangaId,
|
||||
chapterIndex,
|
||||
);
|
||||
const [curPage, setCurPage] = useState<number>(0);
|
||||
const [pageToScrollTo, setPageToScrollTo] = useState<number | undefined>(undefined);
|
||||
const { setOverride, setTitle } = useContext(NavbarContext);
|
||||
@@ -121,7 +118,6 @@ export default function Reader() {
|
||||
|
||||
const { settings: defaultSettings, loading: areDefaultSettingsLoading } = useDefaultReaderSettings();
|
||||
const [settings, setSettings] = useState(getReaderSettingsFor(manga, defaultSettings));
|
||||
const [isMangaLoading, setIsMangaLoading] = useState(true);
|
||||
|
||||
const setSettingValue = (key: keyof IReaderSettings, value: string | boolean) => {
|
||||
setSettings({ ...settings, [key]: value });
|
||||
@@ -156,6 +152,17 @@ export default function Reader() {
|
||||
[chapter, settings],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isChapterLoading || !chapter) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (chapter.lastPageRead === chapter.pageCount - 1) {
|
||||
// last page, also probably read = true, we will load the first page.
|
||||
setCurPage(0);
|
||||
} else setCurPage(chapter.lastPageRead);
|
||||
}, [chapter, isChapterLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!manga?.title || (chapter as IChapter)?.name === t('global.label.loading')) {
|
||||
setTitle(t('reader.title'));
|
||||
@@ -193,52 +200,22 @@ export default function Reader() {
|
||||
return () => setOverride({ status: false, value: <div /> });
|
||||
}, [manga, chapter, settings, curPage, chapterIndex, retrievingNextChapter]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMangaLoading(true);
|
||||
client
|
||||
.get(`/api/v1/manga/${mangaId}/`)
|
||||
.then((response) => response.data)
|
||||
.then((data: IManga) => {
|
||||
setManga(data);
|
||||
setIsMangaLoading(false);
|
||||
});
|
||||
}, [mangaId]);
|
||||
|
||||
useEffect(() => {
|
||||
setChapter(initialChapter);
|
||||
client
|
||||
.get(`/api/v1/manga/${mangaId}/chapter/${chapterIndex}`)
|
||||
.then((response) => response.data)
|
||||
.then((data: IChapter) => {
|
||||
setChapter(data);
|
||||
|
||||
if (data.lastPageRead === data.pageCount - 1) {
|
||||
// last page, also probably read = true, we will load the first page.
|
||||
setCurPage(0);
|
||||
} else setCurPage(data.lastPageRead);
|
||||
});
|
||||
}, [chapterIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (curPage !== -1) {
|
||||
const formData = new FormData();
|
||||
formData.append('lastPageRead', curPage.toString());
|
||||
client.patch(`/api/v1/manga/${manga.id}/chapter/${chapter.index}`, formData);
|
||||
requestManager.updateChapter(manga.id, chapter.index, { lastPageRead: curPage });
|
||||
}
|
||||
|
||||
if (curPage === chapter.pageCount - 1) {
|
||||
const formDataRead = new FormData();
|
||||
formDataRead.append('read', 'true');
|
||||
client.patch(`/api/v1/manga/${manga.id}/chapter/${chapter.index}`, formDataRead);
|
||||
requestManager.updateChapter(manga.id, chapter.index, { read: true });
|
||||
}
|
||||
}, [curPage]);
|
||||
|
||||
const nextChapter = useCallback(() => {
|
||||
if (chapter.index < chapter.chapterCount) {
|
||||
const formData = new FormData();
|
||||
formData.append('lastPageRead', `${chapter.pageCount - 1}`);
|
||||
formData.append('read', 'true');
|
||||
client.patch(`/api/v1/manga/${manga.id}/chapter/${chapter.index}`, formData);
|
||||
requestManager.updateChapter(manga.id, chapter.index, {
|
||||
lastPageRead: chapter.pageCount - 1,
|
||||
read: true,
|
||||
});
|
||||
|
||||
openNextChapter(ChapterOffset.NEXT, (nextChapterIndex) =>
|
||||
history.replace({
|
||||
@@ -278,7 +255,7 @@ export default function Reader() {
|
||||
|
||||
const pages = range(chapter.pageCount).map((index) => ({
|
||||
index,
|
||||
src: `${serverAddress}/api/v1/manga/${mangaId}/chapter/${chapterIndex}/page/${index}`,
|
||||
src: requestManager.getChapterPageUrl(mangaId, chapterIndex, index),
|
||||
}));
|
||||
|
||||
const ReaderComponent = getReaderComponent(settings.readerType);
|
||||
|
||||
@@ -12,15 +12,15 @@ import MangaGrid from 'components/MangaGrid';
|
||||
import LangSelect from 'components/navbar/action/LangSelect';
|
||||
import AppbarSearch from 'components/util/AppbarSearch';
|
||||
import PQueue from 'p-queue';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import React, { useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { StringParam, useQueryParam } from 'use-query-params';
|
||||
import client from 'util/client';
|
||||
import { langSortCmp, sourceDefualtLangs, sourceForcedDefaultLangs } from 'util/language';
|
||||
import useLocalStorage from 'util/useLocalStorage';
|
||||
import { ISource } from 'typings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { translateExtensionLanguage } from 'screens/util/Extensions';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
function sourceToLangList(sources: ISource[]) {
|
||||
const result: string[] = [];
|
||||
@@ -46,9 +46,22 @@ const SearchAll: React.FC = () => {
|
||||
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownSourceLangs', sourceDefualtLangs());
|
||||
const [showNsfw] = useLocalStorage<boolean>('showNsfw', true);
|
||||
|
||||
const [sources, setSources] = useState<ISource[]>([]);
|
||||
const { data: unsortedSources = [], isLoading: FetchedSources } = requestManager.useGetSourceList();
|
||||
const sources = useMemo(
|
||||
() =>
|
||||
unsortedSources.sort((a: { displayName: string }, b: { displayName: string }) => {
|
||||
if (a.displayName < b.displayName) {
|
||||
return -1;
|
||||
}
|
||||
if (a.displayName > b.displayName) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
[unsortedSources],
|
||||
);
|
||||
|
||||
const [fetched, setFetched] = useState<any>({});
|
||||
const [FetchedSources, setFetchedSources] = useState<any>({});
|
||||
|
||||
const [lastPageNum, setLastPageNum] = useState<number>(1);
|
||||
|
||||
@@ -61,32 +74,12 @@ const SearchAll: React.FC = () => {
|
||||
setAction(<AppbarSearch />);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
client
|
||||
.get('/api/v1/source/list')
|
||||
.then((response) => response.data)
|
||||
.then((data) => {
|
||||
setSources(
|
||||
data.sort((a: { displayName: string }, b: { displayName: string }) => {
|
||||
if (a.displayName < b.displayName) {
|
||||
return -1;
|
||||
}
|
||||
if (a.displayName > b.displayName) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
);
|
||||
setFetchedSources(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function doIT(elem: any[]) {
|
||||
elem.map((ele) =>
|
||||
limit.add(async () => {
|
||||
const response = await client.get(
|
||||
`/api/v1/source/${ele.id}/search?searchTerm=${query || ''}&pageNum=1`,
|
||||
);
|
||||
const response = await requestManager
|
||||
.getClient()
|
||||
.get(`/api/v1/source/${ele.id}/search?searchTerm=${query || ''}&pageNum=1`);
|
||||
const data = await response.data;
|
||||
const tmp = mangas;
|
||||
tmp[ele.id] = data.mangaList;
|
||||
|
||||
@@ -41,6 +41,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import { langCodeToName } from 'util/language';
|
||||
import CollectionsOutlinedBookmarkIcon from '@mui/icons-material/CollectionsBookmarkOutlined';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
export default function Settings() {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -52,7 +53,7 @@ export default function Settings() {
|
||||
}, [t]);
|
||||
|
||||
const { darkTheme, setDarkTheme } = useContext(DarkTheme);
|
||||
const [serverAddress, setServerAddress] = useLocalStorage<String>('serverBaseURL', '');
|
||||
const [serverAddress, setServerAddress] = useLocalStorage<string>('serverBaseURL', '');
|
||||
const [showNsfw, setShowNsfw] = useLocalStorage<boolean>('showNsfw', true);
|
||||
const [useCache, setUseCache] = useLocalStorage<boolean>('useCache', true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -73,7 +74,9 @@ export default function Settings() {
|
||||
|
||||
const handleDialogSubmit = () => {
|
||||
setDialogOpen(false);
|
||||
setServerAddress(dialogValue);
|
||||
const serverBaseUrl = dialogValue.replaceAll(/(\/)+$/g, '');
|
||||
setServerAddress(serverBaseUrl);
|
||||
requestManager.updateClient({ baseURL: serverBaseUrl });
|
||||
};
|
||||
|
||||
const handleDialogOpenItemWidth = () => {
|
||||
|
||||
@@ -9,15 +9,14 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import NavbarContext from 'components/context/NavbarContext';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import client, { useQuery } from 'util/client';
|
||||
import { SwitchPreferenceCompat, CheckBoxPreference } from 'components/sourceConfiguration/TwoStatePreference';
|
||||
import ListPreference from 'components/sourceConfiguration/ListPreference';
|
||||
import EditTextPreference from 'components/sourceConfiguration/EditTextPreference';
|
||||
import MultiSelectListPreference from 'components/sourceConfiguration/MultiSelectListPreference';
|
||||
import List from '@mui/material/List';
|
||||
import cloneObject from 'util/cloneObject';
|
||||
import { SourcePreferences } from 'typings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
function getPrefComponent(type: string) {
|
||||
switch (type) {
|
||||
@@ -46,9 +45,7 @@ export default function SourceConfigure() {
|
||||
}, [t]);
|
||||
|
||||
const { sourceId } = useParams<{ sourceId: string }>();
|
||||
const { data: sourcePreferences = [], mutate } = useQuery<SourcePreferences[]>(
|
||||
`/api/v1/source/${sourceId}/preferences`,
|
||||
);
|
||||
const { data: sourcePreferences = [], mutate } = requestManager.useGetSourcePreferences(sourceId);
|
||||
|
||||
const convertToString = (position: number, value: any): string => {
|
||||
switch (sourcePreferences[position].props.defaultValueType) {
|
||||
@@ -60,12 +57,7 @@ export default function SourceConfigure() {
|
||||
};
|
||||
|
||||
const updateValue = (position: number) => (value: any) => {
|
||||
client
|
||||
.post(
|
||||
`/api/v1/source/${sourceId}/preferences`,
|
||||
JSON.stringify({ position, value: convertToString(position, value) }),
|
||||
)
|
||||
.then(() => mutate());
|
||||
requestManager.setSourcePreferences(sourceId, position, convertToString(position, value)).then(() => mutate());
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,16 +11,16 @@ import { useParams, useHistory } from 'react-router-dom';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import SourceMangaGrid from 'components/source/SourceMangaGrid';
|
||||
import NavbarContext from 'components/context/NavbarContext';
|
||||
import client from 'util/client';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import SourceOptions from 'components/source/SourceOptions';
|
||||
import AppbarSearch from 'components/util/AppbarSearch';
|
||||
import { useQueryParam, StringParam } from 'use-query-params';
|
||||
import SourceGridLayout from 'components/source/GridLayouts';
|
||||
import { useLibraryOptionsContext } from 'components/context/LibraryOptionsContext';
|
||||
import { IManga, IMangaCard, ISource, ISourceFilters } from 'typings';
|
||||
import { IManga, IMangaCard, ISourceFilters } from 'typings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Link from '@mui/material/Link';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
interface IPos {
|
||||
position: number;
|
||||
@@ -34,6 +34,9 @@ export default function SourceMangas({ popular }: { popular: boolean }) {
|
||||
const history = useHistory();
|
||||
|
||||
const { sourceId } = useParams<{ sourceId: string }>();
|
||||
|
||||
const { data: source } = requestManager.useGetSource(sourceId);
|
||||
|
||||
const [isConfigurable, setIsConfigurable] = useState<boolean>(false);
|
||||
const [mangas, setMangas] = useState<IMangaCard[]>([]);
|
||||
const [hasNextPage, setHasNextPage] = useState<boolean>(false);
|
||||
@@ -53,7 +56,8 @@ export default function SourceMangas({ popular }: { popular: boolean }) {
|
||||
const { options } = useLibraryOptionsContext();
|
||||
|
||||
function makeFilters() {
|
||||
client
|
||||
requestManager
|
||||
.getClient()
|
||||
.get(`/api/v1/source/${sourceId}/filters`)
|
||||
.then((response) => response.data)
|
||||
.then((data: ISourceFilters[]) => {
|
||||
@@ -66,14 +70,13 @@ export default function SourceMangas({ popular }: { popular: boolean }) {
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
client
|
||||
.get(`/api/v1/source/${sourceId}`)
|
||||
.then((response) => response.data)
|
||||
.then((data: ISource) => {
|
||||
setTitle(data.displayName);
|
||||
setIsConfigurable(data.isConfigurable);
|
||||
});
|
||||
}, []);
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTitle(source.displayName);
|
||||
setIsConfigurable(source.isConfigurable);
|
||||
}, [source]);
|
||||
|
||||
useEffect(() => {
|
||||
if (triggerUpdate === 2) {
|
||||
@@ -86,9 +89,9 @@ export default function SourceMangas({ popular }: { popular: boolean }) {
|
||||
if (update.length > 0) {
|
||||
const rep = update;
|
||||
setUpdate([]);
|
||||
client
|
||||
.post(
|
||||
`/api/v1/source/${sourceId}/filters`,
|
||||
requestManager
|
||||
.setSourceFilters(
|
||||
sourceId,
|
||||
rep.map((e: IPos) => {
|
||||
const { position, state, group }: IPos = e;
|
||||
return group === undefined
|
||||
@@ -125,7 +128,7 @@ export default function SourceMangas({ popular }: { popular: boolean }) {
|
||||
setNoreset(undefined);
|
||||
setReset(1);
|
||||
} else if (Noreset === undefined) {
|
||||
client.get(`/api/v1/source/${sourceId}/filters?reset=true`).then(() => {
|
||||
requestManager.resetSourceFilters(sourceId).then(() => {
|
||||
makeFilters();
|
||||
setSearch(false);
|
||||
if (reset === 1) {
|
||||
@@ -188,7 +191,8 @@ export default function SourceMangas({ popular }: { popular: boolean }) {
|
||||
useEffect(() => {
|
||||
if (lastPageNum !== 0) {
|
||||
const sourceType = popular ? 'popular' : 'latest';
|
||||
client
|
||||
requestManager
|
||||
.getClient()
|
||||
.get(
|
||||
`/api/v1/source/${sourceId}/${
|
||||
query !== undefined || Search || Noreset === null ? 'search' : sourceType
|
||||
|
||||
@@ -16,10 +16,10 @@ import LoadingPlaceholder from 'components/util/LoadingPlaceholder';
|
||||
import { IconButton } from '@mui/material';
|
||||
import TravelExploreIcon from '@mui/icons-material/TravelExplore';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useQuery } from 'util/client';
|
||||
import { ISource } from 'typings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { translateExtensionLanguage } from 'screens/util/Extensions';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
function sourceToLangList(sources: ISource[]) {
|
||||
const result: string[] = [];
|
||||
@@ -53,7 +53,7 @@ export default function Sources() {
|
||||
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownSourceLangs', sourceDefualtLangs());
|
||||
const [showNsfw] = useLocalStorage<boolean>('showNsfw', true);
|
||||
|
||||
const { data: sources, isLoading } = useQuery<ISource[]>('/api/v1/source/list');
|
||||
const { data: sources, isLoading } = requestManager.useGetSourceList();
|
||||
|
||||
const history = useHistory();
|
||||
|
||||
|
||||
@@ -20,11 +20,10 @@ import EmptyView from 'components/util/EmptyView';
|
||||
import LoadingPlaceholder from 'components/util/LoadingPlaceholder';
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
import client from 'util/client';
|
||||
import useLocalStorage from 'util/useLocalStorage';
|
||||
import { IChapter, IMangaChapter, IQueue, PaginatedList } from 'typings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { t as translate } from 'i18next';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
function epochToDate(epoch: number) {
|
||||
const date = new Date(0); // The 0 there is the key, which sets the date to the epoch
|
||||
@@ -66,7 +65,6 @@ function groupByDate(updates: IMangaChapter[]): [string, { item: IMangaChapter;
|
||||
return Object.keys(groups).map((key) => [key, groups[key]]);
|
||||
}
|
||||
|
||||
const baseWebsocketUrl = JSON.parse(window.localStorage.getItem('serverBaseURL')!).replace('http', 'ws');
|
||||
const initialQueue = {
|
||||
status: 'Stopped',
|
||||
queue: [],
|
||||
@@ -82,14 +80,11 @@ const Updates: React.FC = () => {
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const [lastPageNum, setLastPageNum] = useState(0);
|
||||
|
||||
const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
|
||||
const [useCache] = useLocalStorage<boolean>('useCache', true);
|
||||
|
||||
const [, setWsClient] = useState<WebSocket>();
|
||||
const [{ queue }, setQueueState] = useState<IQueue>(initialQueue);
|
||||
|
||||
useEffect(() => {
|
||||
const wsc = new WebSocket(`${baseWebsocketUrl}/api/v1/downloads`);
|
||||
const wsc = requestManager.getDownloadWebSocket();
|
||||
wsc.onmessage = (e) => {
|
||||
const data = JSON.parse(e.data) as IQueue;
|
||||
setQueueState(data);
|
||||
@@ -108,7 +103,8 @@ const Updates: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
client
|
||||
requestManager
|
||||
.getClient()
|
||||
.get(`/api/v1/update/recentChapters/${lastPageNum}`)
|
||||
.then((response) => response.data)
|
||||
.then(({ hasNextPage: fetchedHasNextPage, page }: PaginatedList<IMangaChapter>) => {
|
||||
@@ -149,7 +145,7 @@ const Updates: React.FC = () => {
|
||||
};
|
||||
|
||||
const downloadChapter = (chapter: IChapter) => {
|
||||
client.get(`/api/v1/download/${chapter.mangaId}/chapter/${chapter.index}`);
|
||||
requestManager.addChapterToDownloadQueue(chapter.mangaId, chapter.index);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -200,7 +196,7 @@ const Updates: React.FC = () => {
|
||||
marginRight: 2,
|
||||
imageRendering: 'pixelated',
|
||||
}}
|
||||
src={`${serverAddress}${manga.thumbnailUrl}?useCache=${useCache}`}
|
||||
src={requestManager.getValidImgUrlFor(manga.thumbnailUrl)}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="h5" component="h2">
|
||||
|
||||
@@ -13,9 +13,8 @@ import ListItemText from '@mui/material/ListItemText';
|
||||
import ListItemLink from 'components/util/ListItemLink';
|
||||
import NavbarContext from 'components/context/NavbarContext';
|
||||
import LoadingPlaceholder from 'components/util/LoadingPlaceholder';
|
||||
import { useQuery } from 'util/client';
|
||||
import { IAbout } from 'typings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
export default function About() {
|
||||
const { t } = useTranslation();
|
||||
@@ -26,7 +25,7 @@ export default function About() {
|
||||
setAction(null);
|
||||
}, [t]);
|
||||
|
||||
const { data: about } = useQuery<IAbout>('/api/v1/settings/about');
|
||||
const { data: about } = requestManager.useGetAbout();
|
||||
|
||||
if (about === undefined) {
|
||||
return <LoadingPlaceholder />;
|
||||
|
||||
@@ -11,11 +11,11 @@ import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import { fromEvent } from 'file-selector';
|
||||
import client from 'util/client';
|
||||
import makeToast from 'components/util/Toast';
|
||||
import ListItemLink from 'components/util/ListItemLink';
|
||||
import NavbarContext from 'components/context/NavbarContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
export default function Backup() {
|
||||
const { t } = useTranslation();
|
||||
@@ -25,18 +25,11 @@ export default function Backup() {
|
||||
setAction(null);
|
||||
}, [t]);
|
||||
|
||||
const { baseURL } = client.defaults;
|
||||
|
||||
const submitBackup = (file: File) => {
|
||||
if (file.name.toLowerCase().endsWith('proto.gz')) {
|
||||
const formData = new FormData();
|
||||
formData.append('backup.proto.gz', file);
|
||||
|
||||
makeToast(t('settings.backup.label.restoring_backup'), 'info');
|
||||
client
|
||||
.post('/api/v1/backup/import/file', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
requestManager
|
||||
.restoreBackupFile(file)
|
||||
.then(() => makeToast(t('settings.backup.label.restored_backup'), 'success'))
|
||||
.catch(() => makeToast(t('settings.backup.label.backup_restore_failed'), 'error'));
|
||||
} else if (file.name.toLowerCase().endsWith('json')) {
|
||||
@@ -76,7 +69,7 @@ export default function Backup() {
|
||||
return (
|
||||
<>
|
||||
<List sx={{ padding: 0 }}>
|
||||
<ListItemLink to={`${baseURL}/api/v1/backup/export/file`} directLink>
|
||||
<ListItemLink to={requestManager.getExportBackupUrl()} directLink>
|
||||
<ListItemText
|
||||
primary={t('settings.backup.label.create_backup')}
|
||||
secondary={t('settings.backup.label.create_backup_info')}
|
||||
|
||||
@@ -31,10 +31,10 @@ import DialogTitle from '@mui/material/DialogTitle';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import NavbarContext from 'components/context/NavbarContext';
|
||||
import client, { useQuery } from 'util/client';
|
||||
import { ICategory } from 'typings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DEFAULT_FULL_FAB_HEIGHT } from 'components/util/StyledFab';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
const getItemStyle = (
|
||||
isDragging: boolean,
|
||||
@@ -58,7 +58,7 @@ export default function Categories() {
|
||||
setAction(null);
|
||||
}, [t]);
|
||||
|
||||
const { data, mutate } = useQuery<ICategory[]>('/api/v1/category/');
|
||||
const { data, mutate } = requestManager.useGetCategories();
|
||||
const categories = useMemo(() => {
|
||||
const res = [...(data ?? [])];
|
||||
if (res.length > 0 && res[0].name === 'Default') {
|
||||
@@ -79,10 +79,7 @@ export default function Categories() {
|
||||
newData.splice(to, 0, removed);
|
||||
mutate(newData, { revalidate: false });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('from', `${from + 1}`);
|
||||
formData.append('to', `${to + 1}`);
|
||||
client.patch('/api/v1/category/reorder', formData).finally(() => mutate());
|
||||
requestManager.reorderCategory(from + 1, to + 1).finally(() => mutate());
|
||||
};
|
||||
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
@@ -119,21 +116,19 @@ export default function Categories() {
|
||||
const handleDialogSubmit = () => {
|
||||
setDialogOpen(false);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('name', dialogName);
|
||||
formData.append('default', dialogDefault.toString());
|
||||
|
||||
if (categoryToEdit === -1) {
|
||||
client.post('/api/v1/category/', formData).finally(() => mutate());
|
||||
requestManager.createCategory(dialogName).finally(() => mutate());
|
||||
} else {
|
||||
const category = categories[categoryToEdit];
|
||||
client.patch(`/api/v1/category/${category.id}`, formData).finally(() => mutate());
|
||||
requestManager
|
||||
.updateCategory(category.id, { name: dialogName, default: dialogDefault })
|
||||
.finally(() => mutate());
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCategory = (index: number) => {
|
||||
const category = categories[index];
|
||||
client.delete(`/api/v1/category/${category.id}`).finally(() => mutate());
|
||||
requestManager.deleteCategory(category.id).finally(() => mutate());
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,7 +11,6 @@ import List from '@mui/material/List';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import NavbarContext from 'components/context/NavbarContext';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import client, { useQuery } from 'util/client';
|
||||
import { ICategory, IncludeInGlobalUpdate } from 'typings';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
@@ -25,6 +24,7 @@ import { styled } from '@mui/system';
|
||||
import makeToast from 'components/util/Toast';
|
||||
import { t as translate } from 'i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
const CategoriesDiv = styled('div')({
|
||||
display: 'flex',
|
||||
@@ -77,7 +77,7 @@ export default function LibrarySettings() {
|
||||
setAction(null);
|
||||
}, [t]);
|
||||
|
||||
const { data: categories, isLoading, error: requestError, mutate } = useQuery<ICategory[]>('/api/v1/category/');
|
||||
const { data: categories, isLoading, error: requestError, mutate } = requestManager.useGetCategories();
|
||||
|
||||
const [currentCategories, setCurrentCategories] = useState<ICategory[]>(categories ?? []); // categories to check if response categories changed
|
||||
const [dialogCategories, setDialogCategories] = useState<ICategory[]>(categories ?? []); // categories that are shown and updated in the dialog
|
||||
@@ -110,12 +110,8 @@ export default function LibrarySettings() {
|
||||
requestError,
|
||||
);
|
||||
|
||||
const updateCategory = (category: ICategory) => {
|
||||
const formData = new FormData();
|
||||
formData.append('includeInUpdate', `${category.includeInUpdate}`);
|
||||
|
||||
return client.patch(`/api/v1/category/${category.id}`, formData);
|
||||
};
|
||||
const updateCategory = (category: ICategory) =>
|
||||
requestManager.updateCategory(category.id, { includeInUpdate: category.includeInUpdate });
|
||||
|
||||
const updateCategories = async () => {
|
||||
const categoriesToUpdate = dialogCategories.filter((category) => {
|
||||
|
||||
@@ -379,3 +379,20 @@ export interface BatchChaptersChange {
|
||||
isBookmarked?: boolean;
|
||||
lastPageRead?: number;
|
||||
}
|
||||
|
||||
export type UpdateCheck = {
|
||||
channel: 'Stable' | 'Preview';
|
||||
tag: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type SourceSearchResult = {
|
||||
mangaList: IManga[];
|
||||
hasNextPage: boolean;
|
||||
};
|
||||
|
||||
export type BackupValidationResult = {
|
||||
missingSources: string[];
|
||||
missingTrackers: string[];
|
||||
mangasMissingSources: string[];
|
||||
};
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* 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 axios from 'axios';
|
||||
import useSWR, { SWRConfiguration, SWRResponse } from 'swr';
|
||||
import storage from 'util/localStorage';
|
||||
|
||||
const { hostname, port, protocol } = window.location;
|
||||
|
||||
// if port is 3000 it's probably running from webpack devlopment server
|
||||
let inferredPort;
|
||||
if (port === '3000') {
|
||||
inferredPort = '4567';
|
||||
} else {
|
||||
inferredPort = port;
|
||||
}
|
||||
|
||||
const baseURL = storage.getItem('serverBaseURL', `${protocol}//${hostname}:${inferredPort}`);
|
||||
|
||||
const client = axios.create({
|
||||
// baseURL must not have traling slash
|
||||
baseURL,
|
||||
});
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
if (config.data instanceof FormData) {
|
||||
Object.assign(config.headers, { 'Content-Type': 'multipart/form-data' });
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
export default client;
|
||||
|
||||
export async function fetcher<T = any>(path: string) {
|
||||
const res = await client.get(path);
|
||||
if (res.status !== 200) {
|
||||
throw new Error(res.statusText);
|
||||
}
|
||||
if (res.headers['content-type'] !== 'application/json') {
|
||||
throw new Error('Response is not json');
|
||||
}
|
||||
return res.data as T;
|
||||
}
|
||||
|
||||
export const useQuery = <Data extends any = any, Error extends any = any>(
|
||||
key: string | null,
|
||||
config?: SWRConfiguration<Data, Error>,
|
||||
): SWRResponse<Data, Error> => useSWR(key, config);
|
||||
@@ -7,7 +7,6 @@
|
||||
*/
|
||||
|
||||
import { mutate } from 'swr';
|
||||
import client from 'util/client';
|
||||
import {
|
||||
AllowedMetadataValueTypes,
|
||||
AppMetadataKeys,
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
IMetadataMigration,
|
||||
MetadataKeyValuePair,
|
||||
} from 'typings';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
const APP_METADATA_KEY_PREFIX = 'webUI_';
|
||||
|
||||
@@ -314,7 +314,7 @@ export const requestUpdateMetadataValue = async (
|
||||
[metadataKey]: valueAsString,
|
||||
};
|
||||
|
||||
await client.patch(url, formData);
|
||||
await requestManager.getClient().patch(url, formData);
|
||||
await mutate(
|
||||
urlToMutate,
|
||||
{ ...metadataHolder, ...wrapMetadataWithMetaKey(wrapWithMetaKey, mutatedMetadata) },
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
*/
|
||||
|
||||
import { getMetadataFrom, requestUpdateMangaMetadata, requestUpdateServerMetadata } from 'util/metadata';
|
||||
import { useQuery } from 'util/client';
|
||||
import { IManga, Metadata, MetadataHolder, IReaderSettings, MetadataKeyValuePair } from 'typings';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
type UndefinedReaderSettings = {
|
||||
[setting in keyof IReaderSettings]: IReaderSettings[setting] | undefined;
|
||||
@@ -45,7 +45,7 @@ export const useDefaultReaderSettings = (): {
|
||||
settings: IReaderSettings;
|
||||
loading: boolean;
|
||||
} => {
|
||||
const { data: meta, isLoading } = useQuery<Metadata>('/api/v1/meta');
|
||||
const { data: meta, isLoading } = requestManager.useGetGlobalMeta();
|
||||
const settings = getReaderSettingsWithDefaultValueFallback<IReaderSettings>(meta);
|
||||
|
||||
return { metadata: meta, settings, loading: isLoading };
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { useQuery } from 'util/client';
|
||||
import { getMetadataFrom } from 'util/metadata';
|
||||
import { Metadata, ISearchSettings } from 'typings';
|
||||
import requestManager from 'lib/RequestManager';
|
||||
|
||||
export const getDefaultSettings = (): ISearchSettings => ({
|
||||
ignoreFilters: false,
|
||||
@@ -24,7 +24,7 @@ export const useSearchSettings = (): {
|
||||
settings: ISearchSettings;
|
||||
loading: boolean;
|
||||
} => {
|
||||
const { data: meta, isLoading } = useQuery<Metadata>('/api/v1/meta');
|
||||
const { data: meta, isLoading } = requestManager.useGetGlobalMeta();
|
||||
const settings = getSearchSettingsWithDefaultValueFallback(meta);
|
||||
|
||||
return { metadata: meta, settings, loading: isLoading };
|
||||
|
||||
Reference in New Issue
Block a user