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"