diff --git a/package.json b/package.json
index 2e35624403..8d67b9ed4d 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"react-virtuoso": "^1.8.6",
+ "swr": "^1.3.0",
"use-query-params": "^1.2.3",
"web-vitals": "^2.1.0"
},
diff --git a/src/App.tsx b/src/App.tsx
index f4cd6886fb..0ccf352950 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -21,6 +21,8 @@ import {
Theme,
StyledEngineProvider,
} from '@mui/material/styles';
+import { SWRConfig } from 'swr';
+import { fetcher } from 'util/client';
import DefaultNavBar from 'components/navbar/DefaultNavBar';
import DarkTheme from 'components/context/DarkTheme';
import useLocalStorage from 'util/useLocalStorage';
@@ -83,111 +85,114 @@ export default function App() {
);
return (
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ {/* General Routes */}
+ (
+
+ )}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Manga Routes */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <>>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
- {/* General Routes */}
(
-
+ path="/manga/:mangaId/chapter/:chapterIndex"
+ // passing a key re-mounts the reader
+ // when changing chapters
+ render={(props: any) => (
+
)}
/>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Manga Routes */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- <>>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- (
-
- )}
- />
-
-
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/src/components/MangaDetails.tsx b/src/components/MangaDetails.tsx
index 2ba6a995fa..08aca02e2e 100644
--- a/src/components/MangaDetails.tsx
+++ b/src/components/MangaDetails.tsx
@@ -16,7 +16,9 @@ import React, { useContext, useEffect, useState } from 'react';
import NavbarContext from 'components/context/NavbarContext';
import client from 'util/client';
import useLocalStorage from 'util/useLocalStorage';
+import Refresh from '@mui/icons-material/Refresh';
import CategorySelect from './navbar/action/CategorySelect';
+import LoadingIconButton from './atoms/LoadingIconButton';
const useStyles = (inLibrary: string) => makeStyles((theme: Theme) => ({
root: {
@@ -118,6 +120,8 @@ const useStyles = (inLibrary: string) => makeStyles((theme: Theme) => ({
interface IProps{
manga: IManga
+ onRefresh: () => Promise
+ refreshing: boolean
}
function getSourceName(source: ISource) {
@@ -131,11 +135,9 @@ function getValueOrUnknown(val: string) {
return val || 'UNKNOWN';
}
-export default function MangaDetails(props: IProps) {
+export default function MangaDetails({ manga, onRefresh, refreshing }: IProps) {
const { setAction } = useContext(NavbarContext);
- const { manga } = props;
-
const [inLibrary, setInLibrary] = useState(
manga.inLibrary ? 'In Library' : 'Add To Library',
);
@@ -143,28 +145,32 @@ export default function MangaDetails(props: IProps) {
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
useEffect(() => {
- if (inLibrary === 'In Library') {
- setAction(
- <>
- setCategoryDialogOpen(true)}
- aria-label="display more actions"
- edge="end"
- color="inherit"
- size="large"
- >
-
-
-
- >,
-
- );
- } else { setAction(<>>); }
- }, [inLibrary, categoryDialogOpen]);
+ setAction(
+ <>
+
+
+
+ {inLibrary === 'In Library' && (
+ <>
+ setCategoryDialogOpen(true)}
+ aria-label="display more actions"
+ edge="end"
+ color="inherit"
+ size="large"
+ >
+
+
+
+ >
+ )}
+ >,
+ );
+ }, [inLibrary, categoryDialogOpen, refreshing, onRefresh]);
const [serverAddress] = useLocalStorage('serverBaseURL', '');
const [useCache] = useLocalStorage('useCache', true);
diff --git a/src/components/atoms/LoadingIconButton.tsx b/src/components/atoms/LoadingIconButton.tsx
new file mode 100644
index 0000000000..22fc705fb8
--- /dev/null
+++ b/src/components/atoms/LoadingIconButton.tsx
@@ -0,0 +1,32 @@
+import { CircularProgress, IconButton, IconButtonProps } from '@mui/material';
+import React, { useState } from 'react';
+
+interface IProps extends Omit {
+ loading?: boolean
+ onClick: (e: React.MouseEvent) => Promise
+}
+
+const LoadingIconButton = ({
+ onClick, children, loading: iLoading, ...rest
+}: IProps) => {
+ const [sLoading, setLoading] = useState(false);
+ const loading = sLoading || iLoading;
+
+ const handleClick = (e: React.MouseEvent) => {
+ setLoading(true);
+ onClick(e).finally(() => setLoading(false));
+ };
+
+ return (
+ // eslint-disable-next-line react/jsx-props-no-spreading
+
+ {loading ? () : children}
+
+ );
+};
+
+LoadingIconButton.defaultProps = {
+ loading: false,
+};
+
+export default LoadingIconButton;
diff --git a/src/components/chapter/ChapterList.tsx b/src/components/chapter/ChapterList.tsx
index 2c7908e231..831845b77b 100644
--- a/src/components/chapter/ChapterList.tsx
+++ b/src/components/chapter/ChapterList.tsx
@@ -5,7 +5,9 @@
* 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 React, { useState, useEffect, useCallback } from 'react';
+import React, {
+ useState, useEffect, useCallback, useMemo,
+} from 'react';
import { Box, styled } from '@mui/system';
import { Virtuoso } from 'react-virtuoso';
import Typography from '@mui/material/Typography';
@@ -19,7 +21,7 @@ import {
filterAndSortChapters,
} from 'components/chapter/util';
import ResumeFab from 'components/chapter/ResumeFAB';
-import useFetchChapters from './useFetchChapters';
+import useSubscription from 'components/library/useSubscription';
const CustomVirtuoso = styled(Virtuoso)(({ theme }) => ({
listStyle: 'none',
@@ -33,20 +35,16 @@ const CustomVirtuoso = styled(Virtuoso)(({ theme }) => ({
},
}));
-const baseWebsocketUrl = JSON.parse(window.localStorage.getItem('serverBaseURL')!).replace('http', 'ws');
-const initialQueue = {
- status: 'Stopped',
- queue: [],
-} as IQueue;
-
interface IProps {
id: string
+ chaptersData: IChapter[] | undefined
+ onRefresh: () => void;
}
-export default function ChapterList(props: IProps) {
- const { id } = props;
+export default function ChapterList({ id, chaptersData, onRefresh }: IProps) {
+ const noChaptersFound = chaptersData?.length === 0;
+ const chapters = useMemo(() => chaptersData ?? [], [chaptersData]);
- const [chapters, triggerChaptersUpdate, noChaptersFound] = useFetchChapters(id);
const [firstUnreadChapter, setFirstUnreadChapter] = useState();
const [filteredChapters, setFilteredChapters] = useState([]);
// eslint-disable-next-line max-len
@@ -54,31 +52,14 @@ export default function ChapterList(props: IProps) {
chapterOptionsReducer, `${id}filterOptions`, defaultChapterOptions,
);
- const [, setWsClient] = useState();
- const [{ queue }, setQueueState] = useState(initialQueue);
-
- useEffect(() => {
- const wsc = new WebSocket(`${baseWebsocketUrl}/api/v1/downloads`);
- wsc.onmessage = (e) => {
- const data = JSON.parse(e.data) as IQueue;
- setQueueState(data);
- };
-
- setWsClient(wsc);
-
- return () => wsc.close();
- }, []);
-
- useEffect(() => {
- triggerChaptersUpdate();
- }, [queue.length]);
+ const queue = useSubscription('/api/v1/downloads').data?.queue;
const downloadStatusStringFor = useCallback((chapter: IChapter) => {
let rtn = '';
if (chapter.downloaded) {
rtn = ' • Downloaded';
}
- queue.forEach((q) => {
+ queue?.forEach((q) => {
if (chapter.index === q.chapterIndex && chapter.mangaId === q.mangaId) {
rtn = ` • Downloading (${(q.progress * 100).toFixed(2)}%)`;
}
@@ -136,7 +117,7 @@ export default function ChapterList(props: IProps) {
showChapterNumber={options.showChapterNumber}
chapter={filteredChapters[index]}
downloadStatusString={downloadStatusStringFor(filteredChapters[index])}
- triggerChaptersUpdate={triggerChaptersUpdate}
+ triggerChaptersUpdate={onRefresh}
/>
)}
useWindowScroll={window.innerWidth < 900}
diff --git a/src/components/chapter/useFetchChapters.ts b/src/components/chapter/useFetchChapters.ts
deleted file mode 100644
index b836a691da..0000000000
--- a/src/components/chapter/useFetchChapters.ts
+++ /dev/null
@@ -1,39 +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 { useState, useCallback, useEffect } from 'react';
-import client from 'util/client';
-
-export default function useChaptersFetch(id: string): [IChapter[], () => void, boolean] {
- const [chapters, setChapters] = useState([]);
- const [noChaptersFound, setNoChaptersFound] = useState(false);
- const [chapterUpdateTriggerer, setChapterUpdateTriggerer] = useState(0);
- const [fetchedOnline, setFetchedOnline] = useState(false);
- const [fetchedOffline, setFetchedOffline] = useState(false);
-
- const triggerChaptersUpdate = useCallback(() => setChapterUpdateTriggerer((prev) => prev + 1),
- []);
-
- useEffect(() => {
- const shouldFetchOnline = fetchedOffline && !fetchedOnline;
-
- client.get(`/api/v1/manga/${id}/chapters?onlineFetch=${shouldFetchOnline}`)
- .then((response) => response.data)
- .then((data) => {
- if (data.length === 0 && fetchedOffline) {
- setNoChaptersFound(true);
- }
- setChapters(data);
- })
- .then(() => {
- if (shouldFetchOnline) {
- setFetchedOnline(true);
- } else setFetchedOffline(true);
- });
- }, [fetchedOnline, fetchedOffline, chapterUpdateTriggerer]);
-
- return [chapters, triggerChaptersUpdate, noChaptersFound];
-}
diff --git a/src/components/library/useSubscription.ts b/src/components/library/useSubscription.ts
new file mode 100644
index 0000000000..874525941b
--- /dev/null
+++ b/src/components/library/useSubscription.ts
@@ -0,0 +1,30 @@
+import { useEffect, useState } from 'react';
+
+const baseWebsocketUrl = JSON.parse(window.localStorage.getItem('serverBaseURL')!).replace('http', 'ws');
+
+const useSubscription = (path: string, callback?: (newValue: T) => boolean | void) => {
+ const [state, setState] = useState();
+
+ useEffect(() => {
+ const wsc = new WebSocket(`${baseWebsocketUrl}${path}`);
+
+ wsc.onmessage = (e) => {
+ const data = JSON.parse(e.data) as T;
+ if (callback) {
+ // If callback is specified, only update state if callback returns true
+ // This is so that useSubscription can be used without causing rerender
+ if (callback(data) === true) {
+ setState(data);
+ }
+ } else {
+ setState(data);
+ }
+ };
+
+ return () => wsc.close();
+ }, [path]);
+
+ return { data: state };
+};
+
+export default useSubscription;
diff --git a/src/screens/Library.tsx b/src/screens/Library.tsx
index f904c0438c..7092ea2526 100644
--- a/src/screens/Library.tsx
+++ b/src/screens/Library.tsx
@@ -6,10 +6,10 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import { Tab, Tabs } from '@mui/material';
-import React, { useContext, useEffect, useState } from 'react';
+import React, {
+ useContext, useEffect, useState,
+} from 'react';
import NavbarContext from 'components/context/NavbarContext';
-import client from 'util/client';
-import cloneObject from 'util/cloneObject';
import EmptyView from 'components/util/EmptyView';
import LoadingPlaceholder from 'components/util/LoadingPlaceholder';
import TabPanel from 'components/util/TabPanel';
@@ -17,18 +17,25 @@ import LibraryOptions from 'components/library/LibraryOptions';
import LibraryMangaGrid from 'components/library/LibraryMangaGrid';
import AppbarSearch from 'components/util/AppbarSearch';
import { useQueryParam, NumberParam } from 'use-query-params';
+import useSWR from 'swr';
import UpdateChecker from '../components/library/UpdateChecker';
-interface IMangaCategory {
- category: ICategory
- mangas: IManga[]
- isFetched: boolean
-}
-
export default function Library() {
+ const { data: tabsData, error: tabsError } = useSWR('/api/v1/category');
+ const tabs = tabsData ?? [];
+
+ const [tabSearchParam, setTabSearchParam] = useQueryParam('tab', NumberParam);
+
+ const activeTab = tabs.find((t) => t.order === tabSearchParam) ?? tabs[0];
+ const { data: mangaData, error: mangaError } = useSWR(`/api/v1/category/${activeTab?.id}`, {
+ isPaused: () => activeTab == null,
+ });
+ const mangas = mangaData ?? [];
+
const { setTitle, setAction } = useContext(NavbarContext);
useEffect(() => {
- setTitle('Library'); setAction(
+ setTitle('Library');
+ setAction(
<>
@@ -37,64 +44,19 @@ export default function Library() {
);
}, []);
- const [tabs, setTabs] = useState();
-
- const [tabNum, setTabNum] = useState(0);
- const [tabSearchParam, setTabSearchParam] = useQueryParam('tab', NumberParam);
-
// a hack so MangaGrid doesn't stop working. I won't change it in case
// if I do manga pagination for library..
const [lastPageNum, setLastPageNum] = useState(1);
const handleTabChange = (newTab: number) => {
- setTabNum(newTab);
- setTabSearchParam(newTab);
+ setTabSearchParam(newTab === 0 ? undefined : newTab);
};
- useEffect(() => {
- client.get('/api/v1/category')
- .then((response) => response.data)
- .then((categories: ICategory[]) => {
- const categoryTabs = categories.map((category) => ({
- category,
- mangas: [] as IManga[],
- isFetched: false,
- }));
- setTabs(categoryTabs);
- if (categoryTabs.length > 0) {
- if (
- tabSearchParam !== undefined
- && tabSearchParam !== null
- && !Number.isNaN(tabSearchParam)
- && categories.some((category) => category.order === Number(tabSearchParam))
- ) {
- handleTabChange(Number(tabSearchParam!));
- } else { handleTabChange(categoryTabs[0].category.order); }
- }
- });
- }, []);
-
- // fetch the current tab
- useEffect(() => {
- if (tabs !== undefined) {
- tabs.forEach((tab, index) => {
- if (tab.category.order === tabNum && !tab.isFetched) {
- // eslint-disable-next-line @typescript-eslint/no-shadow
- client.get(`/api/v1/category/${tab.category.id}`)
- .then((response) => response.data)
- .then((data: IManga[]) => {
- const tabsClone = cloneObject(tabs);
- tabsClone[index].mangas = data;
- tabsClone[index].isFetched = true;
-
- setTabs(tabsClone);
- });
- }
- });
- }
- }, [tabs?.length, tabNum]);
+ if (tabsError != null) {
+ return ;
+ }
- if (tabs === undefined) {
+ if (tabsData == null) {
return ;
}
@@ -102,57 +64,57 @@ export default function Library() {
return ;
}
- let toRender;
- if (tabs.length > 1) {
- // eslint-disable-next-line max-len
- const tabDefines = tabs.map((tab) => ());
-
- const tabBodies = tabs.map((tab) => (
-
-
-
- ));
-
- // Visual Hack: 160px is min-width for viewport width of >600
- const scrollableTabs = window.innerWidth < tabs.length * 160;
- toRender = (
- <>
- handleTabChange(newTab)}
- indicatorColor="primary"
- textColor="primary"
- centered={!scrollableTabs}
- variant={scrollableTabs ? 'scrollable' : 'fullWidth'}
- scrollButtons
- allowScrollButtonsMobile
- >
- {tabDefines}
-
- {tabBodies}
- >
- );
- } else {
- const mangas = tabs.length === 1 ? tabs[0].mangas : [];
- toRender = (
+ if (tabs.length === 1) {
+ return (
);
}
- return toRender;
+ // Visual Hack: 160px is min-width for viewport width of >600
+ const scrollableTabs = window.innerWidth < tabs.length * 160;
+
+ return (
+ <>
+ handleTabChange(newTab)}
+ indicatorColor="primary"
+ textColor="primary"
+ centered={!scrollableTabs}
+ variant={scrollableTabs ? 'scrollable' : 'fullWidth'}
+ scrollButtons
+ allowScrollButtonsMobile
+ >
+ {tabs.map((tab) => (
+
+ ))}
+
+ {tabs.map((tab) => (
+
+ {tab === activeTab && (mangaError
+ ? (
+
+ )
+ : (
+
+ ))}
+
+ ))}
+ >
+ );
}
diff --git a/src/screens/Manga.tsx b/src/screens/Manga.tsx
index 8f71f207f7..907aa0125e 100644
--- a/src/screens/Manga.tsx
+++ b/src/screens/Manga.tsx
@@ -5,43 +5,70 @@
* 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 React, { useEffect, useState, useContext } from 'react';
+import React, {
+ useCallback, useEffect, useContext, useState, useRef,
+} from 'react';
+import useSWR from 'swr';
import { Box } from '@mui/system';
import MangaDetails from 'components/MangaDetails';
import NavbarContext from 'components/context/NavbarContext';
-import client from 'util/client';
+import { fetcher } from 'util/client';
import LoadingPlaceholder from 'components/util/LoadingPlaceholder';
import ChapterList from 'components/chapter/ChapterList';
import { useParams } from 'react-router-dom';
+const AUTOFETCH_AGE = 60 * 60 * 24; // 24 hours
+
export default function Manga() {
const { setTitle } = useContext(NavbarContext);
- useEffect(() => { setTitle('Manga'); }, []); // delegate setting topbar action to MangaDetails
-
const { id } = useParams<{ id: string }>();
+ const autofetchedRef = useRef(false);
+
+ const { data: manga, error, mutate: mutateManga } = useSWR(`/api/v1/manga/${id}/?onlineFetch=false`);
+ const { data: chaptersData, mutate: mutateChapters } = useSWR(`/api/v1/manga/${id}/chapters?onlineFetch=false`);
- const [manga, setManga] = useState();
+ const [fetchingOnline, setFetchingOnline] = useState(false);
+ const fetchOnline = useCallback(async () => {
+ setFetchingOnline(true);
+ await Promise.all([
+ fetcher(`/api/v1/manga/${id}/?onlineFetch=true`)
+ .then((res) => mutateManga(res, { revalidate: false })),
+ fetcher(`/api/v1/manga/${id}/chapters?onlineFetch=true`)
+ .then((res) => mutateChapters(res, { revalidate: false })),
+ ]);
+ setFetchingOnline(false);
+ }, [mutateManga, mutateChapters, id]);
useEffect(() => {
- if (manga === undefined || !manga.freshData) {
- client.get(`/api/v1/manga/${id}/?onlineFetch=${manga !== undefined}`)
- .then((response) => response.data)
- .then((data: IManga) => {
- setManga(data);
- setTitle(data.title);
- });
+ // Automatically fetch manga from source if data is older then 24 hours
+ // Automatic fetch is done only once, to prevent issues when server does
+ // not update age for some reason (ie. error on source side)
+ if (manga == null) return;
+ if (
+ manga.inLibrary
+ && (manga.age > AUTOFETCH_AGE || manga.chaptersAge > AUTOFETCH_AGE)
+ && autofetchedRef.current === false
+ ) {
+ autofetchedRef.current = true;
+ fetchOnline();
}
}, [manga]);
+ useEffect(() => {
+ setTitle(manga?.title ?? 'Manga');
+ }, [manga?.title]);
+
return (
-
-
-
+ {!manga && !error && }
+ {manga && (
+
+ )}
+ mutateChapters()} />
);
}
diff --git a/src/typings.d.ts b/src/typings.d.ts
index f05859ac93..0f4b3748e2 100644
--- a/src/typings.d.ts
+++ b/src/typings.d.ts
@@ -85,6 +85,9 @@ interface IManga {
freshData: boolean
unreadCount?: number
downloadCount?: number
+
+ age: number
+ chaptersAge: number
}
interface IChapter {
diff --git a/src/util/client.tsx b/src/util/client.tsx
index 024d1aeaf4..f7b6f61153 100644
--- a/src/util/client.tsx
+++ b/src/util/client.tsx
@@ -14,9 +14,11 @@ const { hostname, port, protocol } = window.location;
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: storage.getItem('serverBaseURL', `${protocol}//${hostname}:${inferredPort}`),
+ baseURL,
});
client.interceptors.request.use((config) => {
@@ -27,3 +29,14 @@ client.interceptors.request.use((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;
+}
diff --git a/yarn.lock b/yarn.lock
index fb83b2cf31..1e5b8a2174 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -11006,6 +11006,11 @@ svgo@^1.0.0, svgo@^1.2.2:
unquote "~1.1.1"
util.promisify "~1.0.0"
+swr@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/swr/-/swr-1.3.0.tgz#c6531866a35b4db37b38b72c45a63171faf9f4e8"
+ integrity sha512-dkghQrOl2ORX9HYrMDtPa7LTVHJjCTeZoB1dqTbnnEDlSvN8JEKpYIYurDfvbQFUUS8Cg8PceFVZNkW0KNNYPw==
+
symbol-tree@^3.2.4:
version "3.2.4"
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"