Feature/updates screen use infinite swr hook (#303)

* Use "RequestManager" for loading updates

* [Cleanup] Fix typing

* Memoize grouped updates

* [TSConfig] Increase target version

Makes it possible to iterate over a "IterableIterator" (e.g. [...<Map>.entries()])

* Use "GroupedVirtuoso" for "Updates" screen

* Optionally use "padding" instead of margin for the loading placeholder

In case the loading placeholder gets used in a "react-virtuoso" list, the margin might cause issues with the calculated height.

* Use "padding" instead of "margin"

"Margin" breaks "react-virtuosos" height calculation

* Add background color to group header

With virtuoso the group headers are sticky, thus, a background color is needed

* Simplify "groupByDate" object structure

It's not necessary to save the items inside the group object
This commit is contained in:
schroda
2023-05-18 13:48:01 +02:00
committed by GitHub
parent a457d80c44
commit aa801a57ee
3 changed files with 150 additions and 132 deletions

View File

@@ -15,10 +15,11 @@ interface IProps {
children?: React.ReactNode; children?: React.ReactNode;
component?: string | React.FunctionComponent<any> | React.ComponentClass<any, any>; component?: string | React.FunctionComponent<any> | React.ComponentClass<any, any>;
componentProps?: any; componentProps?: any;
usePadding?: boolean;
} }
export default function LoadingPlaceholder(props: IProps) { export default function LoadingPlaceholder(props: IProps) {
const { children, shouldRender, component, componentProps } = props; const { children, shouldRender, component, componentProps, usePadding } = props;
let condition = true; let condition = true;
if (shouldRender !== undefined) { if (shouldRender !== undefined) {
@@ -38,7 +39,10 @@ export default function LoadingPlaceholder(props: IProps) {
return ( return (
<Box <Box
sx={{ sx={{
margin: '10px auto', margin: '0px auto',
marginTop: usePadding ? 'unset' : '10px',
marginBottom: usePadding ? 'unset' : '10px',
padding: usePadding ? '10px 0' : 'unset',
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
}} }}

View File

@@ -13,17 +13,50 @@ import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent'; import CardContent from '@mui/material/CardContent';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { Box } from '@mui/system'; import { Box, styled } from '@mui/system';
import NavbarContext from 'components/context/NavbarContext'; import NavbarContext from 'components/context/NavbarContext';
import DownloadStateIndicator from 'components/molecules/DownloadStateIndicator'; import DownloadStateIndicator from 'components/molecules/DownloadStateIndicator';
import EmptyView from 'components/util/EmptyView'; import EmptyView from 'components/util/EmptyView';
import LoadingPlaceholder from 'components/util/LoadingPlaceholder'; import LoadingPlaceholder from 'components/util/LoadingPlaceholder';
import React, { useContext, useEffect, useRef, useState } from 'react'; import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { Link, useHistory } from 'react-router-dom'; import { Link, useHistory } from 'react-router-dom';
import { IChapter, IMangaChapter, IQueue, PaginatedList } from 'typings'; import { IChapter, IMangaChapter, IQueue } from 'typings';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { t as translate } from 'i18next'; import { t as translate } from 'i18next';
import requestManager from 'lib/RequestManager'; import requestManager from 'lib/RequestManager';
import { GroupedVirtuoso } from 'react-virtuoso';
const StyledGroupedVirtuoso = styled(GroupedVirtuoso)(({ theme }) => ({
// 64px header
height: 'calc(100vh - 64px)',
[theme.breakpoints.down('sm')]: {
// 64px header (margin); 64px menu (margin);
height: 'calc(100vh - 64px - 64px)',
},
}));
const StyledGroupHeader = styled(Typography, { shouldForwardProp: (prop) => prop !== 'isFirstItem' })<{
isFirstItem: boolean;
}>(({ theme, isFirstItem }) => ({
paddingLeft: '24px',
// 16px - 10px (bottom padding of the group items)
paddingTop: '6px',
paddingBottom: '16px',
fontWeight: 700,
textTransform: 'uppercase',
backgroundColor: theme.palette.background.default,
[theme.breakpoints.down('sm')]: {
// 16px - 8px (margin of header)
paddingTop: isFirstItem ? '8px' : '6px',
},
}));
const StyledGroupItemWrapper = styled(Box, { shouldForwardProp: (prop) => prop !== 'isLastItem' })<{
isLastItem: boolean;
}>(({ isLastItem }) => ({
padding: '0 10px',
paddingBottom: isLastItem ? '0' : '10px',
}));
function epochToDate(epoch: number) { function epochToDate(epoch: number) {
const date = new Date(0); // The 0 there is the key, which sets the date to the epoch const date = new Date(0); // The 0 there is the key, which sets the date to the epoch
@@ -49,21 +82,19 @@ function getDateString(date: Date) {
return date.toLocaleDateString(); return date.toLocaleDateString();
} }
function groupByDate(updates: IMangaChapter[]): [string, { item: IMangaChapter; globalIdx: number }[]][] { const groupByDate = (updates: IMangaChapter[]): [date: string, items: number][] => {
if (updates.length === 0) return []; if (!updates.length) {
return [];
}
const groups = {}; const dateToItemMap = new Map<string, number>();
updates.forEach((item, globalIdx) => { updates.forEach((item) => {
const key = getDateString(epochToDate(item.chapter.fetchedAt)); const date = getDateString(epochToDate(item.chapter.fetchedAt));
// @ts-ignore dateToItemMap.set(date, (dateToItemMap.get(date) ?? 0) + 1);
if (groups[key] === undefined) groups[key] = [];
// @ts-ignore
groups[key].push({ item, globalIdx });
}); });
// @ts-ignore return [...dateToItemMap.entries()];
return Object.keys(groups).map((key) => [key, groups[key]]); };
}
const initialQueue = { const initialQueue = {
status: 'Stopped', status: 'Stopped',
@@ -75,10 +106,19 @@ const Updates: React.FC = () => {
const history = useHistory(); const history = useHistory();
const { setTitle, setAction } = useContext(NavbarContext); const { setTitle, setAction } = useContext(NavbarContext);
const [updateEntries, setUpdateEntries] = useState<IMangaChapter[]>([]); const {
const [hasNextPage, setHasNextPage] = useState(true); data: pages = [{ hasNextPage: false, page: [] }],
const [fetched, setFetched] = useState(false); isLoading,
const [lastPageNum, setLastPageNum] = useState(0); size: loadedPages,
setSize: setPages,
} = requestManager.useGetRecentlyUpdatedChapters();
const { hasNextPage } = pages[pages.length - 1];
const updateEntries = useMemo(
() => pages.map((page) => page.page).reduce((lastPageChapters, chapters) => [...lastPageChapters, ...chapters]),
[pages],
);
const groupedUpdates = useMemo(() => groupByDate(updateEntries), [updateEntries]);
const groupCounts: number[] = useMemo(() => groupedUpdates.map((group) => group[1]), [groupedUpdates]);
const [, setWsClient] = useState<WebSocket>(); const [, setWsClient] = useState<WebSocket>();
const [{ queue }, setQueueState] = useState<IQueue>(initialQueue); const [{ queue }, setQueueState] = useState<IQueue>(initialQueue);
@@ -101,41 +141,7 @@ const Updates: React.FC = () => {
setAction(null); setAction(null);
}, [t]); }, [t]);
useEffect(() => { if (!isLoading && updateEntries.length === 0) {
if (hasNextPage) {
requestManager
.getClient()
.get(`/api/v1/update/recentChapters/${lastPageNum}`)
.then((response) => response.data)
.then(({ hasNextPage: fetchedHasNextPage, page }: PaginatedList<IMangaChapter>) => {
setUpdateEntries([...updateEntries, ...page]);
setHasNextPage(fetchedHasNextPage);
setFetched(true);
});
}
}, [lastPageNum]);
const lastEntry = useRef<HTMLDivElement>(null);
const scrollHandler = () => {
if (lastEntry.current) {
const rect = lastEntry.current.getBoundingClientRect();
if ((rect.y + rect.height) / window.innerHeight < 2 && hasNextPage) {
setLastPageNum(lastPageNum + 1);
}
}
};
useEffect(() => {
window.addEventListener('scroll', scrollHandler, true);
return () => {
window.removeEventListener('scroll', scrollHandler, true);
};
}, [hasNextPage, updateEntries]);
if (!fetched) {
return <LoadingPlaceholder />;
}
if (fetched && updateEntries.length === 0) {
return <EmptyView message={t('updates.error.label.no_updates_available')} />; return <EmptyView message={t('updates.error.label.no_updates_available')} />;
} }
@@ -148,29 +154,38 @@ const Updates: React.FC = () => {
requestManager.addChapterToDownloadQueue(chapter.mangaId, chapter.index); requestManager.addChapterToDownloadQueue(chapter.mangaId, chapter.index);
}; };
const loadMore = useCallback(() => {
if (!hasNextPage) {
return;
}
setPages(loadedPages + 1);
}, [hasNextPage, loadedPages]);
return ( return (
<> <StyledGroupedVirtuoso
{groupByDate(updateEntries).map((dateGroup) => ( style={{
<div key={dateGroup[0]}> // override Virtuoso default values and set them with class
<Typography height: 'undefined',
variant="h5"
sx={{
ml: 3,
my: 2,
fontWeight: 700,
textTransform: 'uppercase',
}} }}
> components={{
{dateGroup[0]} Footer: () => (isLoading ? <LoadingPlaceholder usePadding /> : null),
</Typography> }}
{dateGroup[1].map(({ item: { chapter, manga }, globalIdx }) => { overscan={window.innerHeight * 0.5}
endReached={loadMore}
groupCounts={groupCounts}
groupContent={(index) => (
<StyledGroupHeader variant="h5" isFirstItem={index === 0}>
{groupedUpdates[index][0]}
</StyledGroupHeader>
)}
itemContent={(index) => {
const { chapter, manga } = updateEntries[index];
const download = downloadForChapter(chapter); const download = downloadForChapter(chapter);
return ( return (
<Card <StyledGroupItemWrapper key={index} isLastItem={index === updateEntries.length - 1}>
ref={globalIdx === updateEntries.length - 1 ? lastEntry : undefined} <Card>
key={globalIdx}
sx={{ margin: '10px' }}
>
<CardActionArea <CardActionArea
component={Link} component={Link}
to={{ to={{
@@ -223,11 +238,10 @@ const Updates: React.FC = () => {
</CardContent> </CardContent>
</CardActionArea> </CardActionArea>
</Card> </Card>
</StyledGroupItemWrapper>
); );
})} }}
</div> />
))}
</>
); );
}; };

View File

@@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": "./src", "baseUrl": "./src",
"target": "es5", "target": "es2015",
"lib": [ "lib": [
"dom", "dom",
"dom.iterable", "dom.iterable",