diff --git a/src/components/ExtensionCard.tsx b/src/components/ExtensionCard.tsx index 8e509fca..7229fe09 100644 --- a/src/components/ExtensionCard.tsx +++ b/src/components/ExtensionCard.tsx @@ -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('serverBaseURL', ''); - const [useCache] = useLocalStorage('useCache', true); - const langPress = lang === 'all' ? t('extension.language.all') : lang.toUpperCase(); const requestExtensionAction = async (action: ExtensionAction): Promise => { @@ -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)} /> diff --git a/src/components/MangaCard.tsx b/src/components/MangaCard.tsx index 85cc8614..fda9fa99 100644 --- a/src/components/MangaCard.tsx +++ b/src/components/MangaCard.tsx @@ -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((props: IProps, ref) options: { showUnreadBadge, showDownloadBadge }, } = useLibraryOptionsContext(); - const [serverAddress] = useLocalStorage('serverBaseURL', ''); - const [useCache] = useLocalStorage('useCache', true); const [ItemWidth] = useLocalStorage('ItemWidth', 300); const mangaLinkTo = { pathname: `/manga/${id}/`, state: { backLink: BACK } }; @@ -145,7 +144,7 @@ const MangaCard = React.forwardRef((props: IProps, ref) ((props: IProps, ref) imageRendering: 'pixelated', } } - src={`${serverAddress}${thumbnailUrl}?useCache=${useCache}`} + src={requestManager.getValidImgUrlFor(thumbnailUrl)} /> ({ display: 'flex', @@ -51,9 +51,6 @@ const SourceCard: React.FC = (props: IProps) => { const history = useHistory(); - const [serverAddress] = useLocalStorage('serverBaseURL', ''); - const [useCache] = useLocalStorage('useCache', true); - const redirectTo = (e: any, to: string) => { history.push(to); @@ -86,7 +83,7 @@ const SourceCard: React.FC = (props: IProps) => { flex: '0 0 auto', mr: 2, }} - src={`${serverAddress}${iconUrl}?useCache=${useCache}`} + src={requestManager.getValidImgUrlFor(iconUrl)} /> = ({ children }) => { const theme = useMemo(() => createTheme(darkTheme), [darkTheme]); return ( - + diff --git a/src/components/library/UpdateChecker.tsx b/src/components/library/UpdateChecker.tsx index 54d664be..fbddc0f1 100644 --- a/src/components/library/UpdateChecker.tsx +++ b/src/components/library/UpdateChecker.tsx @@ -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 diff --git a/src/components/library/useSubscription.ts b/src/components/library/useSubscription.ts index abaf2087..7a0e9b25 100644 --- a/src/components/library/useSubscription.ts +++ b/src/components/library/useSubscription.ts @@ -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 = (path: string, callback?: (newValue: T) => boolean | void) => { const [state, setState] = useState(); 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; diff --git a/src/components/manga/ChapterCard.tsx b/src/components/manga/ChapterCard.tsx index e12f876d..47385244 100644 --- a/src/components/manga/ChapterCard.tsx +++ b/src/components/manga/ChapterCard.tsx @@ -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 = (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(); }; diff --git a/src/components/manga/ChapterList.tsx b/src/components/manga/ChapterList.tsx index 33b5e049..1a453911 100644 --- a/src/components/manga/ChapterList.tsx +++ b/src/components/manga/ChapterList.tsx @@ -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 = ({ mangaId }) => { const [selection, setSelection] = useState(null); const prevQueueRef = useRef(); - const queue = useSubscription('/api/v1/downloads').data?.queue; + const queue = useSubscription('downloads').data?.queue; const [options, dispatch] = useChapterOptions(mangaId); - const { - data: chaptersData, - mutate, - isLoading, - } = useQuery(`/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 = ({ mangaId }) => { let actionPromise: Promise; 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 = ({ mangaId }) => { change.lastPageRead = 0; } - actionPromise = client.post('/api/v1/chapter/batch', { chapterIds, change }); + actionPromise = requestManager.updateChapters(chapterIds, change); } actionPromise diff --git a/src/components/manga/MangaDetails.tsx b/src/components/manga/MangaDetails.tsx index 6082c136..3edbb310 100644 --- a/src/components/manga/MangaDetails.tsx +++ b/src/components/manga/MangaDetails.tsx @@ -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 = ({ manga }) => { const { t } = useTranslation(); - const [serverAddress] = useLocalStorage('serverBaseURL', ''); - const [useCache] = useLocalStorage('useCache', true); - const classes = useStyles(manga.inLibrary)(); useEffect(() => { @@ -148,17 +144,13 @@ const MangaDetails: React.FC = ({ 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 = ({ manga }) => {
- Manga Thumbnail + Manga Thumbnail

{manga.title}

diff --git a/src/components/manga/hooks.ts b/src/components/manga/hooks.ts index ee4d5ffe..9f16c2dc 100644 --- a/src/components/manga/hooks.ts +++ b/src/components/manga/hooks.ts @@ -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]); diff --git a/src/components/navbar/action/CategorySelect.tsx b/src/components/navbar/action/CategorySelect.tsx index 57687d36..a723cbe0 100644 --- a/src/components/navbar/action/CategorySelect.tsx +++ b/src/components/navbar/action/CategorySelect.tsx @@ -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(`/api/v1/manga/${mangaId}/category`); - const { data: categoriesData } = useQuery('/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, 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 ( diff --git a/src/components/reader/Page.tsx b/src/components/reader/Page.tsx index abe77885..bede75ad 100644 --- a/src/components/reader/Page.tsx +++ b/src/components/reader/Page.tsx @@ -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('useCache', true); - const imgRef = useRef(null); const imgStyle = imageStyle(settings); @@ -77,7 +74,7 @@ const Page = React.forwardRef((props: IProps, ref: any) => { return ( = { + skipRequest?: boolean; + getEndpoint?: (index: number, previousData: Data | null) => string | null; +}; + +type SWROptions = SWRConfiguration & CustomSWROptions; +type SWRInfiniteOptions = SWRInfiniteConfiguration & CustomSWROptions; + +// 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 = SWROptions, + >( + url: string, + httpMethod: DefaultHttpMethod, + { + data, + axiosOptions, + swrOptions, + }: { + data?: Data; + axiosOptions?: AxiosRequestConfig; + swrOptions?: OptionsSWR; + } = {}, + ): SWRResponse { + 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( + getEndpoint: Required>['getEndpoint'], + { + axiosOptions, + swrOptions, + }: { axiosOptions?: AxiosRequestConfig; swrOptions?: SWRInfiniteConfiguration } = {}, + ): SWRInfiniteResponse { + // useSWRInfinite will (by default) revalidate the first page, to check if the other pages have to be revalidated as well + const result = useSWRInfinite( + (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 | 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): SWRResponse { + return this.doRequest(HttpMethod.SWR_GET, 'meta', { swrOptions }); + } + + public setGlobalMetadata(key: string, value: any): Promise { + return this.doRequest(HttpMethod.PATCH, 'meta', { formData: { key, value } }); + } + + public useGetAbout(swrOptions?: SWROptions): SWRResponse { + return this.doRequest(HttpMethod.SWR_GET, 'settings/about', { swrOptions }); + } + + public useCheckForUpdate(swrOptions?: SWROptions): SWRResponse { + return this.doRequest(HttpMethod.SWR_GET, 'settings/check-update', { swrOptions }); + } + + public useGetExtensionList(swrOptions?: SWROptions): SWRResponse { + return this.doRequest(HttpMethod.SWR_GET, 'extension/list', { swrOptions }); + } + + public installExtension(extension: string | File): Promise { + 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 { + return this.doRequest(HttpMethod.GET, `extension/update/${extension}`); + } + + public uninstallExtension(extension: string): Promise { + return this.doRequest(HttpMethod.GET, `extension/uninstall/${extension}`); + } + + public getExtensionIconUrl(extension: string): string { + return this.getValidImgUrlFor(`extension/icon/${extension}`); + } + + public useGetSourceList(swrOptions?: SWROptions): SWRResponse { + return this.doRequest(HttpMethod.SWR_GET, 'source/list', { swrOptions }); + } + + public useGetSource(sourceId: string, swrOptions?: SWROptions): SWRResponse { + return this.doRequest(HttpMethod.SWR_GET, `source/${sourceId}`, { swrOptions }); + } + + public useGetSourcePopularMangas( + sourceId: string, + extension: string, + initialPages?: number, + swrOptions?: SWRInfiniteOptions>, + ): SWRInfiniteResponse> { + 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>, + ): SWRInfiniteResponse> { + 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, + ): SWRResponse { + return this.doRequest(HttpMethod.SWR_GET, `source/${sourceId}/preferences`, { swrOptions }); + } + + public setSourcePreferences(sourceId: string, position: number, value: string): Promise { + return this.doRequest(HttpMethod.POST, `source/${sourceId}/preferences`, { data: { position, value } }); + } + + public useGetSourceFilters( + sourceId: string, + reset?: boolean, + swrOptions?: SWROptions, + ): SWRResponse { + return this.doRequest(HttpMethod.SWR_GET, `source/${sourceId}/filters`, { swrOptions }); + } + + public setSourceFilters(sourceId: string, filters: { position: number; state: string }[]): Promise { + return this.doRequest(HttpMethod.POST, `source/${sourceId}/filters`, { data: { filters } }); + } + + public resetSourceFilters(sourceId: string): Promise { + return this.doRequest(HttpMethod.GET, `source/${sourceId}/filters?reset=true`); + } + + public useSourceSearch( + sourceId: string, + searchTerm: string, + initialPages?: number, + swrOptions?: SWRInfiniteOptions, + ): SWRInfiniteResponse { + 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, + ): SWRInfiniteResponse { + 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 & RequestOption = {}, + ): SWRResponse { + const onlineFetch = doOnlineFetch ? '?onlineFetch=true' : ''; + return this.doRequest(HttpMethod.SWR_GET, `manga/${mangaId}${onlineFetch}`, { + swrOptions, + }); + } + + public useGetFullManga( + mangaId: number | string, + { doOnlineFetch, ...swrOptions }: SWROptions & RequestOption = {}, + ): SWRResponse { + 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): SWRResponse { + return this.doRequest(HttpMethod.SWR_GET, `manga/${mangaId}/category`, { swrOptions }); + } + + public addMangaToCategory(mangaId: number, categoryId: number): Promise { + return this.doRequest(HttpMethod.GET, `manga/${mangaId}/category/${categoryId}`); + } + + public removeMangaFromCategory(mangaId: number, categoryId: number): Promise { + return this.doRequest(HttpMethod.DELETE, `manga/${mangaId}/category/${categoryId}`); + } + + public addMangaToLibrary(mangaId: number | string): Promise { + return this.doRequest(HttpMethod.GET, `manga/${mangaId}/library`); + } + + public removeMangaFromLibrary(mangaId: number | string): Promise { + return this.doRequest(HttpMethod.DELETE, `manga/${mangaId}/library`); + } + + public setMangaMeta(mangaId: number, key: string, value: any): Promise { + return this.doRequest(HttpMethod.POST, `manga/${mangaId}/meta`, { formData: { key, value } }); + } + + public useGetMangaChapters( + mangaId: number | string, + { doOnlineFetch, ...swrOptions }: SWROptions & RequestOption = {}, + ): SWRResponse { + 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 { + return this.doRequest(HttpMethod.POST, `manga/${mangaId}/chapter/batch`, { + data: { + chapterIds, + chapterIndexes, + change, + }, + }); + } + + public useGetChapter( + mangaId: number | string, + chapterIndex: number | string, + swrOptions?: SWROptions, + ): SWRResponse { + return this.doRequest(HttpMethod.SWR_GET, `manga/${mangaId}/chapter/${chapterIndex}`, { + swrOptions, + }); + } + + public deleteDownloadedChapter(mangaId: number | string, chapterIndex: number | string): Promise { + 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 { + return this.doRequest(HttpMethod.PATCH, `manga/${mangaId}/chapter/${chapterIndex}`, { formData: change }); + } + + public setChapterMeta( + mangaId: number | string, + chapterIndex: number | string, + key: string, + value: any, + ): Promise { + 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 { + return this.doRequest(HttpMethod.POST, `chapter/batch`, { data: { chapterIds, change } }); + } + + public useGetCategories(swrOptions?: SWROptions): SWRResponse { + return this.doRequest(HttpMethod.SWR_GET, `category`, { swrOptions }); + } + + public createCategory(name: string): Promise { + return this.doRequest(HttpMethod.POST, `category`, { formData: { name } }); + } + + public reorderCategory(currentPosition: number, newPosition: number): Promise { + return this.doRequest(HttpMethod.PATCH, `category/reorder`, { + formData: { from: currentPosition, to: newPosition }, + }); + } + + public useGetCategoryMangas(categoryId: number, swrOptions?: SWROptions): SWRResponse { + return this.doRequest(HttpMethod.SWR_GET, `category/${categoryId}`, { swrOptions }); + } + + public deleteCategory(categoryId: number): Promise { + return this.doRequest(HttpMethod.DELETE, `category/${categoryId}`); + } + + public updateCategory( + categoryId: number, + change: { name?: string; default?: boolean; includeInUpdate?: IncludeInGlobalUpdate } = {}, + ): Promise { + return this.doRequest(HttpMethod.PATCH, `category/${categoryId}`, { formData: change }); + } + + public setCategoryMeta(categoryId: number, key: string, value: any): Promise { + return this.doRequest(HttpMethod.PATCH, `category/${categoryId}`, { formData: { key, value } }); + } + + public restoreBackupFile(file: File): Promise { + return this.doRequest(HttpMethod.POST, 'backup/import/file', { formData: { 'backup.proto.gz': file } }); + } + + public useValidateBackupFile( + file: File, + swrOptions?: SWROptions, + ): SWRResponse { + 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 { + return this.doRequest(HttpMethod.GET, 'downloads/start'); + } + + public stopDownloads(): Promise { + return this.doRequest(HttpMethod.GET, 'downloads/stop'); + } + + public clearDownloads(): Promise { + return this.doRequest(HttpMethod.GET, 'downloads/clear'); + } + + public addChapterToDownloadQueue(mangaId: number | string, chapterIndex: number | string): Promise { + return this.doRequest(HttpMethod.GET, `download/${mangaId}/chapter/${chapterIndex}`); + } + + public removeChapterFromDownloadQueue( + mangaId: number | string, + chapterIndex: number | string, + ): Promise { + return this.doRequest(HttpMethod.DELETE, `download/${mangaId}/chapter/${chapterIndex}`); + } + + public reorderChapterInDownloadQueue( + mangaId: number | string, + chapterIndex: number | string, + position: number, + ): Promise { + return this.doRequest(HttpMethod.PATCH, `download/${mangaId}/chapter/${chapterIndex}/reorder/${position}`); + } + + public addChaptersToDownloadQueue(chapterIds: number[]): Promise { + return this.doRequest(HttpMethod.POST, 'download/batch', { data: { chapterIds } }); + } + + public removeChaptersFromDownloadQueue(chapterIds: number[]): Promise { + return this.doRequest(HttpMethod.DELETE, 'download/batch', { data: { chapterIds } }); + } + + public useGetRecentlyUpdatedChapters( + initialPages?: number, + swrOptions?: SWRInfiniteOptions>, + ): SWRInfiniteResponse> { + 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 { + return this.doRequest(HttpMethod.POST, 'update/fetch', { formData: { categoryId } }); + } + + public resetGlobalUpdate(): Promise { + return this.doRequest(HttpMethod.POST, 'update/reset'); + } + + public useGetGlobalUpdateSummary(swrOptions?: SWROptions): SWRResponse { + return this.doRequest(HttpMethod.SWR_GET, 'update/summary', { swrOptions }); + } +} + +const requestManager = new RequestManager(); +export default requestManager; diff --git a/src/lib/RestClient.ts b/src/lib/RestClient.ts new file mode 100644 index 00000000..20b51300 --- /dev/null +++ b/src/lib/RestClient.ts @@ -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: Data; +}; + +export interface IRestClient { + get>(url: string): Promise; + delete>(url: string): Promise; + post>(url: string, data?: any): Promise; + put>(url: string, data?: any): Promise; + patch>(url: string, data?: any): Promise; +} + +export class RestClient implements IRestClient { + protected client!: AxiosInstance; + + constructor() { + this.createClient(); + } + + public readonly fetcher = async ( + url: string, + { + data, + httpMethod = HttpMethod.GET, + config, + checkResponseIsJson = true, + }: { + data?: any; + httpMethod?: HttpMethod; + config?: AxiosRequestConfig; + checkResponseIsJson?: boolean; + } = {}, + ): Promise => { + let result: AxiosResponse; + + 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; + } +} diff --git a/src/screens/DownloadQueue.tsx b/src/screens/DownloadQueue.tsx index 30acf7fd..0f7fe414 100644 --- a/src/screens/DownloadQueue.tsx +++ b/src/screens/DownloadQueue.tsx @@ -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('/api/v1/downloads'); + const { data: queueState } = useSubscription('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 ( diff --git a/src/screens/Extensions.tsx b/src/screens/Extensions.tsx index 235e41ac..692f13ca 100644 --- a/src/screens/Extensions.tsx +++ b/src/screens/Extensions.tsx @@ -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('/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(); diff --git a/src/screens/Library.tsx b/src/screens/Library.tsx index 668635af..31d73028 100644 --- a/src/screens/Library.tsx +++ b/src/screens/Library.tsx @@ -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('/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(activeTab ? `/api/v1/category/${activeTab?.id}` : null); + } = requestManager.useGetCategoryMangas(activeTab?.id, { skipRequest: !activeTab }); const mangas = mangaData ?? []; const { setTitle, setAction } = useContext(NavbarContext); diff --git a/src/screens/Manga.tsx b/src/screens/Manga.tsx index 4a493b16..44029553 100644 --- a/src/screens/Manga.tsx +++ b/src/screens/Manga.tsx @@ -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(`/api/v1/manga/${id}/?onlineFetch=false`); + const { data: manga, error, isLoading, isValidating, mutate } = requestManager.useGetManga(id); const [refresh, { loading: refreshing }] = useRefreshManga(id); diff --git a/src/screens/Reader.tsx b/src/screens/Reader.tsx index c3a508ca..5fbfb6ff 100644 --- a/src/screens/Reader.tsx +++ b/src/screens/Reader.tsx @@ -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(`/api/v1/manga/${currentChapter.mangaId}/chapter/${chapterIndex}`)) - .data; + const nextChapter = ( + await requestManager + .getClient() + .get(`/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('serverBaseURL', ''); - const { chapterIndex, mangaId } = useParams<{ chapterIndex: string; mangaId: string }>(); - const [manga, setManga] = useState({ - id: +mangaId, - title: '', - thumbnailUrl: '', - genre: [], - inLibraryAt: 0, - lastReadAt: 0, - }); - const [chapter, setChapter] = useState(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(0); const [pageToScrollTo, setPageToScrollTo] = useState(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:
}); }, [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); diff --git a/src/screens/SearchAll.tsx b/src/screens/SearchAll.tsx index 12d5cc50..54a1c074 100644 --- a/src/screens/SearchAll.tsx +++ b/src/screens/SearchAll.tsx @@ -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('shownSourceLangs', sourceDefualtLangs()); const [showNsfw] = useLocalStorage('showNsfw', true); - const [sources, setSources] = useState([]); + 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({}); - const [FetchedSources, setFetchedSources] = useState({}); const [lastPageNum, setLastPageNum] = useState(1); @@ -61,32 +74,12 @@ const SearchAll: React.FC = () => { setAction(); }, [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; diff --git a/src/screens/Settings.tsx b/src/screens/Settings.tsx index 9db46193..1e5c8cfa 100644 --- a/src/screens/Settings.tsx +++ b/src/screens/Settings.tsx @@ -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('serverBaseURL', ''); + const [serverAddress, setServerAddress] = useLocalStorage('serverBaseURL', ''); const [showNsfw, setShowNsfw] = useLocalStorage('showNsfw', true); const [useCache, setUseCache] = useLocalStorage('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 = () => { diff --git a/src/screens/SourceConfigure.tsx b/src/screens/SourceConfigure.tsx index b7d57724..be9af866 100644 --- a/src/screens/SourceConfigure.tsx +++ b/src/screens/SourceConfigure.tsx @@ -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( - `/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 ( diff --git a/src/screens/SourceMangas.tsx b/src/screens/SourceMangas.tsx index d09daa9b..09991e5c 100644 --- a/src/screens/SourceMangas.tsx +++ b/src/screens/SourceMangas.tsx @@ -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(false); const [mangas, setMangas] = useState([]); const [hasNextPage, setHasNextPage] = useState(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 diff --git a/src/screens/Sources.tsx b/src/screens/Sources.tsx index 94625112..c4f3ef54 100644 --- a/src/screens/Sources.tsx +++ b/src/screens/Sources.tsx @@ -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('shownSourceLangs', sourceDefualtLangs()); const [showNsfw] = useLocalStorage('showNsfw', true); - const { data: sources, isLoading } = useQuery('/api/v1/source/list'); + const { data: sources, isLoading } = requestManager.useGetSourceList(); const history = useHistory(); diff --git a/src/screens/Updates.tsx b/src/screens/Updates.tsx index 897f2e0a..bc0091bc 100644 --- a/src/screens/Updates.tsx +++ b/src/screens/Updates.tsx @@ -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('serverBaseURL', ''); - const [useCache] = useLocalStorage('useCache', true); - const [, setWsClient] = useState(); const [{ queue }, setQueueState] = useState(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) => { @@ -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)} /> diff --git a/src/screens/settings/About.tsx b/src/screens/settings/About.tsx index 7f8b55ac..c349fce7 100644 --- a/src/screens/settings/About.tsx +++ b/src/screens/settings/About.tsx @@ -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('/api/v1/settings/about'); + const { data: about } = requestManager.useGetAbout(); if (about === undefined) { return ; diff --git a/src/screens/settings/Backup.tsx b/src/screens/settings/Backup.tsx index ad4e3e34..b0fb7ba5 100644 --- a/src/screens/settings/Backup.tsx +++ b/src/screens/settings/Backup.tsx @@ -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 ( <> - + ('/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 ( diff --git a/src/screens/settings/LibrarySettings.tsx b/src/screens/settings/LibrarySettings.tsx index 57f44fd7..fdacf582 100644 --- a/src/screens/settings/LibrarySettings.tsx +++ b/src/screens/settings/LibrarySettings.tsx @@ -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('/api/v1/category/'); + const { data: categories, isLoading, error: requestError, mutate } = requestManager.useGetCategories(); const [currentCategories, setCurrentCategories] = useState(categories ?? []); // categories to check if response categories changed const [dialogCategories, setDialogCategories] = useState(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) => { diff --git a/src/typings.ts b/src/typings.ts index 965ceabf..1d15f695 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -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[]; +}; diff --git a/src/util/client.tsx b/src/util/client.tsx deleted file mode 100644 index 877cb71b..00000000 --- a/src/util/client.tsx +++ /dev/null @@ -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(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 = ( - key: string | null, - config?: SWRConfiguration, -): SWRResponse => useSWR(key, config); diff --git a/src/util/metadata.ts b/src/util/metadata.ts index 50c3758a..93d9278f 100644 --- a/src/util/metadata.ts +++ b/src/util/metadata.ts @@ -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) }, diff --git a/src/util/readerSettings.ts b/src/util/readerSettings.ts index 53924c38..ba9acacb 100644 --- a/src/util/readerSettings.ts +++ b/src/util/readerSettings.ts @@ -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('/api/v1/meta'); + const { data: meta, isLoading } = requestManager.useGetGlobalMeta(); const settings = getReaderSettingsWithDefaultValueFallback(meta); return { metadata: meta, settings, loading: isLoading }; diff --git a/src/util/searchSettings.ts b/src/util/searchSettings.ts index 796f1c18..26731286 100644 --- a/src/util/searchSettings.ts +++ b/src/util/searchSettings.ts @@ -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('/api/v1/meta'); + const { data: meta, isLoading } = requestManager.useGetGlobalMeta(); const settings = getSearchSettingsWithDefaultValueFallback(meta); return { metadata: meta, settings, loading: isLoading };