From a0b0b711f031faba2e4a5c85af0175e50a379140 Mon Sep 17 00:00:00 2001 From: Valter Martinek Date: Fri, 28 Oct 2022 11:16:38 +0200 Subject: [PATCH 1/7] Wrap data fetching in library and manga screen with swr for better dev and user experience. Refactor some API calls and data handling. Add manual refresh to manga detail. --- package.json | 1 + src/App.tsx | 207 +++++++++++---------- src/components/MangaDetails.tsx | 10 +- src/components/atoms/LoadingIconButton.tsx | 24 +++ src/components/chapter/ChapterList.tsx | 52 +++--- src/components/chapter/useFetchChapters.ts | 39 ---- src/components/library/useSubscription.ts | 30 +++ src/screens/Library.tsx | 168 +++++++---------- src/screens/Manga.tsx | 32 ++-- src/util/client.tsx | 15 +- yarn.lock | 5 + 11 files changed, 289 insertions(+), 294 deletions(-) create mode 100644 src/components/atoms/LoadingIconButton.tsx delete mode 100644 src/components/chapter/useFetchChapters.ts create mode 100644 src/components/library/useSubscription.ts 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..37dc7f2627 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,7 @@ const useStyles = (inLibrary: string) => makeStyles((theme: Theme) => ({ interface IProps{ manga: IManga + onRefresh: () => Promise } function getSourceName(source: ISource) { @@ -131,11 +134,9 @@ function getValueOrUnknown(val: string) { return val || 'UNKNOWN'; } -export default function MangaDetails(props: IProps) { +export default function MangaDetails({ manga, onRefresh }: IProps) { const { setAction } = useContext(NavbarContext); - const { manga } = props; - const [inLibrary, setInLibrary] = useState( manga.inLibrary ? 'In Library' : 'Add To Library', ); @@ -195,6 +196,9 @@ export default function MangaDetails(props: IProps) { return (
+ + +
diff --git a/src/components/atoms/LoadingIconButton.tsx b/src/components/atoms/LoadingIconButton.tsx new file mode 100644 index 0000000000..19421ca765 --- /dev/null +++ b/src/components/atoms/LoadingIconButton.tsx @@ -0,0 +1,24 @@ +import { CircularProgress, IconButton, IconButtonProps } from '@mui/material'; +import React, { useState } from 'react'; + +interface IProps extends Omit { + onClick: (e: React.MouseEvent) => Promise +} + +const LoadingIconButton = ({ onClick, children, ...rest }: IProps) => { + const [loading, setLoading] = useState(false); + + const handleClick = (e: React.MouseEvent) => { + setLoading(true); + onClick(e).finally(() => setLoading(false)); + }; + + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + {loading ? () : children} + + ); +}; + +export default LoadingIconButton; diff --git a/src/components/chapter/ChapterList.tsx b/src/components/chapter/ChapterList.tsx index 2c7908e231..d345d5fcff 100644 --- a/src/components/chapter/ChapterList.tsx +++ b/src/components/chapter/ChapterList.tsx @@ -5,9 +5,13 @@ * 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 { Refresh } from '@mui/icons-material'; import { Virtuoso } from 'react-virtuoso'; +import useSWR from 'swr'; import Typography from '@mui/material/Typography'; import { CircularProgress, Stack } from '@mui/material'; import makeToast from 'components/util/Toast'; @@ -19,7 +23,9 @@ import { filterAndSortChapters, } from 'components/chapter/util'; import ResumeFab from 'components/chapter/ResumeFAB'; -import useFetchChapters from './useFetchChapters'; +import useSubscription from 'components/library/useSubscription'; +import LoadingIconButton from 'components/atoms/LoadingIconButton'; +import { fetcher } from 'util/client'; const CustomVirtuoso = styled(Virtuoso)(({ theme }) => ({ listStyle: 'none', @@ -33,12 +39,6 @@ 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 } @@ -46,7 +46,15 @@ interface IProps { export default function ChapterList(props: IProps) { const { id } = props; - const [chapters, triggerChaptersUpdate, noChaptersFound] = useFetchChapters(id); + const { data: chaptersData, mutate } = useSWR(`/api/v1/manga/${id}/chapters?onlineFetch=false`); + const fetchLive = useCallback(async () => { + const res = await fetcher(`/api/v1/manga/${id}/chapters?onlineFetch=true`); + mutate(res, { revalidate: false }); + }, [mutate, id]); + const noChaptersFound = chaptersData?.length === 0; + const chapters = useMemo(() => chaptersData ?? [], [chaptersData]); + const triggerChaptersUpdate = mutate; + const [firstUnreadChapter, setFirstUnreadChapter] = useState(); const [filteredChapters, setFilteredChapters] = useState([]); // eslint-disable-next-line max-len @@ -54,31 +62,18 @@ 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(); - }, []); + const queue = useSubscription('/api/v1/downloads').data?.queue; useEffect(() => { triggerChaptersUpdate(); - }, [queue.length]); + }, [queue?.length]); 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)}%)`; } @@ -115,12 +110,15 @@ export default function ChapterList(props: IProps) { <> - + {`${filteredChapters.length} Chapters`} + + + 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..8e88f11cf9 100644 --- a/src/screens/Manga.tsx +++ b/src/screens/Manga.tsx @@ -5,11 +5,12 @@ * 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 } 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'; @@ -20,27 +21,18 @@ export default function Manga() { const { id } = useParams<{ id: string }>(); - const [manga, setManga] = useState(); - - 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); - }); - } - }, [manga]); + const { + data: manga, error, mutate, + } = useSWR(`/api/v1/manga/${id}/?onlineFetch=false`); + const fetchOnline = useCallback(async () => { + const res = await fetcher(`/api/v1/manga/${id}/?onlineFetch=true`); + mutate(res, { revalidate: false }); + }, [mutate, id]); return ( - - + {!manga && !error && } + {manga && } ); 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" From d85da3d8385e1ef64feed1789e4cd4591c8bd563 Mon Sep 17 00:00:00 2001 From: Valter Martinek Date: Fri, 28 Oct 2022 11:17:27 +0200 Subject: [PATCH 2/7] Fix manga card layout --- src/components/MangaCard.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/MangaCard.tsx b/src/components/MangaCard.tsx index c70f102fab..e55e34d952 100644 --- a/src/components/MangaCard.tsx +++ b/src/components/MangaCard.tsx @@ -73,10 +73,10 @@ interface IProps { gridLayout: number | undefined dimensions: number } + const MangaCard = React.forwardRef((props: IProps, ref) => { const { manga: { - // eslint-disable-next-line @typescript-eslint/no-unused-vars id, title, thumbnailUrl, downloadCount, unreadCount: unread, inLibrary, }, gridLayout, @@ -89,10 +89,9 @@ const MangaCard = React.forwardRef((props: IProps, ref) const [ItemWidth] = useLocalStorage('ItemWidth', 300); if (gridLayout !== 2) { - const colomns = Math.round(dimensions / ItemWidth); + const cols = Math.floor(dimensions / ItemWidth); return ( - // @ts-ignore gridsize type isnt allowed to be a decimal but it works fine - + Date: Fri, 28 Oct 2022 11:28:34 +0200 Subject: [PATCH 3/7] Fix refresh button position on small screens --- src/components/MangaDetails.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/MangaDetails.tsx b/src/components/MangaDetails.tsx index 37dc7f2627..a2ffef704f 100644 --- a/src/components/MangaDetails.tsx +++ b/src/components/MangaDetails.tsx @@ -23,6 +23,9 @@ import LoadingIconButton from './atoms/LoadingIconButton'; const useStyles = (inLibrary: string) => makeStyles((theme: Theme) => ({ root: { width: '100%', + [theme.breakpoints.down('md')]: { + position: 'relative', + }, [theme.breakpoints.up('md')]: { position: 'sticky', top: '64px', From 9103cca14f370ed4e69ec71c3d9f0a78af784136 Mon Sep 17 00:00:00 2001 From: Valter Martinek Date: Sun, 30 Oct 2022 16:08:09 +0100 Subject: [PATCH 4/7] Revert "Fix manga card layout" This reverts commit d85da3d8385e1ef64feed1789e4cd4591c8bd563. --- src/components/MangaCard.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/MangaCard.tsx b/src/components/MangaCard.tsx index e55e34d952..c70f102fab 100644 --- a/src/components/MangaCard.tsx +++ b/src/components/MangaCard.tsx @@ -73,10 +73,10 @@ interface IProps { gridLayout: number | undefined dimensions: number } - const MangaCard = React.forwardRef((props: IProps, ref) => { const { manga: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars id, title, thumbnailUrl, downloadCount, unreadCount: unread, inLibrary, }, gridLayout, @@ -89,9 +89,10 @@ const MangaCard = React.forwardRef((props: IProps, ref) const [ItemWidth] = useLocalStorage('ItemWidth', 300); if (gridLayout !== 2) { - const cols = Math.floor(dimensions / ItemWidth); + const colomns = Math.round(dimensions / ItemWidth); return ( - + // @ts-ignore gridsize type isnt allowed to be a decimal but it works fine + Date: Mon, 31 Oct 2022 16:34:02 +0100 Subject: [PATCH 5/7] Fix manga not setting title --- src/screens/Manga.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/screens/Manga.tsx b/src/screens/Manga.tsx index 8e88f11cf9..af7bc1e8d8 100644 --- a/src/screens/Manga.tsx +++ b/src/screens/Manga.tsx @@ -29,6 +29,12 @@ export default function Manga() { mutate(res, { revalidate: false }); }, [mutate, id]); + useEffect(() => { + if (manga?.title) { + setTitle(manga.title); + } + }, [manga?.title]); + return ( {!manga && !error && } From b05cc031f19979b50005b494d468e4536ac9b85a Mon Sep 17 00:00:00 2001 From: Valter Martinek Date: Mon, 31 Oct 2022 17:09:15 +0100 Subject: [PATCH 6/7] Move chapters loading to Manga component, join data fetching, add auto online fetch if fetched data is old --- src/components/MangaDetails.tsx | 54 +++++++++++---------- src/components/atoms/LoadingIconButton.tsx | 12 ++++- src/components/chapter/ChapterList.tsx | 25 ++-------- src/screens/Manga.tsx | 55 +++++++++++++++++----- src/typings.d.ts | 3 ++ 5 files changed, 87 insertions(+), 62 deletions(-) diff --git a/src/components/MangaDetails.tsx b/src/components/MangaDetails.tsx index a2ffef704f..797cfaeddc 100644 --- a/src/components/MangaDetails.tsx +++ b/src/components/MangaDetails.tsx @@ -124,6 +124,7 @@ const useStyles = (inLibrary: string) => makeStyles((theme: Theme) => ({ interface IProps{ manga: IManga onRefresh: () => Promise + refreshing: boolean } function getSourceName(source: ISource) { @@ -137,7 +138,7 @@ function getValueOrUnknown(val: string) { return val || 'UNKNOWN'; } -export default function MangaDetails({ manga, onRefresh }: IProps) { +export default function MangaDetails({ manga, onRefresh, refreshing }: IProps) { const { setAction } = useContext(NavbarContext); const [inLibrary, setInLibrary] = useState( @@ -147,28 +148,32 @@ export default function MangaDetails({ manga, onRefresh }: 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); @@ -199,9 +204,6 @@ export default function MangaDetails({ manga, onRefresh }: IProps) { return (
- - -
diff --git a/src/components/atoms/LoadingIconButton.tsx b/src/components/atoms/LoadingIconButton.tsx index 19421ca765..22fc705fb8 100644 --- a/src/components/atoms/LoadingIconButton.tsx +++ b/src/components/atoms/LoadingIconButton.tsx @@ -2,11 +2,15 @@ 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, ...rest }: IProps) => { - const [loading, setLoading] = useState(false); +const LoadingIconButton = ({ + onClick, children, loading: iLoading, ...rest +}: IProps) => { + const [sLoading, setLoading] = useState(false); + const loading = sLoading || iLoading; const handleClick = (e: React.MouseEvent) => { setLoading(true); @@ -21,4 +25,8 @@ const LoadingIconButton = ({ onClick, children, ...rest }: IProps) => { ); }; +LoadingIconButton.defaultProps = { + loading: false, +}; + export default LoadingIconButton; diff --git a/src/components/chapter/ChapterList.tsx b/src/components/chapter/ChapterList.tsx index d345d5fcff..33419f0b21 100644 --- a/src/components/chapter/ChapterList.tsx +++ b/src/components/chapter/ChapterList.tsx @@ -9,9 +9,7 @@ import React, { useState, useEffect, useCallback, useMemo, } from 'react'; import { Box, styled } from '@mui/system'; -import { Refresh } from '@mui/icons-material'; import { Virtuoso } from 'react-virtuoso'; -import useSWR from 'swr'; import Typography from '@mui/material/Typography'; import { CircularProgress, Stack } from '@mui/material'; import makeToast from 'components/util/Toast'; @@ -24,8 +22,6 @@ import { } from 'components/chapter/util'; import ResumeFab from 'components/chapter/ResumeFAB'; import useSubscription from 'components/library/useSubscription'; -import LoadingIconButton from 'components/atoms/LoadingIconButton'; -import { fetcher } from 'util/client'; const CustomVirtuoso = styled(Virtuoso)(({ theme }) => ({ listStyle: 'none', @@ -41,19 +37,13 @@ const CustomVirtuoso = styled(Virtuoso)(({ theme }) => ({ interface IProps { id: string + chaptersData: IChapter[] | undefined + onRefresh: () => void; } -export default function ChapterList(props: IProps) { - const { id } = props; - - const { data: chaptersData, mutate } = useSWR(`/api/v1/manga/${id}/chapters?onlineFetch=false`); - const fetchLive = useCallback(async () => { - const res = await fetcher(`/api/v1/manga/${id}/chapters?onlineFetch=true`); - mutate(res, { revalidate: false }); - }, [mutate, id]); +export default function ChapterList({ id, chaptersData, onRefresh }: IProps) { const noChaptersFound = chaptersData?.length === 0; const chapters = useMemo(() => chaptersData ?? [], [chaptersData]); - const triggerChaptersUpdate = mutate; const [firstUnreadChapter, setFirstUnreadChapter] = useState(); const [filteredChapters, setFilteredChapters] = useState([]); @@ -64,10 +54,6 @@ export default function ChapterList(props: IProps) { const queue = useSubscription('/api/v1/downloads').data?.queue; - useEffect(() => { - triggerChaptersUpdate(); - }, [queue?.length]); - const downloadStatusStringFor = useCallback((chapter: IChapter) => { let rtn = ''; if (chapter.downloaded) { @@ -116,9 +102,6 @@ export default function ChapterList(props: IProps) { {`${filteredChapters.length} Chapters`} - - - @@ -134,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/screens/Manga.tsx b/src/screens/Manga.tsx index af7bc1e8d8..907aa0125e 100644 --- a/src/screens/Manga.tsx +++ b/src/screens/Manga.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, { useCallback, useEffect, 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'; @@ -15,31 +17,58 @@ 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 { - data: manga, error, mutate, - } = useSWR(`/api/v1/manga/${id}/?onlineFetch=false`); + const [fetchingOnline, setFetchingOnline] = useState(false); const fetchOnline = useCallback(async () => { - const res = await fetcher(`/api/v1/manga/${id}/?onlineFetch=true`); - mutate(res, { revalidate: false }); - }, [mutate, id]); + 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?.title) { - setTitle(manga.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 && } - + {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 { From 9e31c5b3eb0db76b66a1dc48553bc1c1c30c6975 Mon Sep 17 00:00:00 2001 From: Valter Martinek Date: Mon, 31 Oct 2022 17:12:06 +0100 Subject: [PATCH 7/7] Revert some changes --- src/components/MangaDetails.tsx | 3 --- src/components/chapter/ChapterList.tsx | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/components/MangaDetails.tsx b/src/components/MangaDetails.tsx index 797cfaeddc..08aca02e2e 100644 --- a/src/components/MangaDetails.tsx +++ b/src/components/MangaDetails.tsx @@ -23,9 +23,6 @@ import LoadingIconButton from './atoms/LoadingIconButton'; const useStyles = (inLibrary: string) => makeStyles((theme: Theme) => ({ root: { width: '100%', - [theme.breakpoints.down('md')]: { - position: 'relative', - }, [theme.breakpoints.up('md')]: { position: 'sticky', top: '64px', diff --git a/src/components/chapter/ChapterList.tsx b/src/components/chapter/ChapterList.tsx index 33419f0b21..831845b77b 100644 --- a/src/components/chapter/ChapterList.tsx +++ b/src/components/chapter/ChapterList.tsx @@ -96,10 +96,10 @@ export default function ChapterList({ id, chaptersData, onRefresh }: IProps) { <> - + {`${filteredChapters.length} Chapters`}