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,86 +154,94 @@ 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={{ components={{
ml: 3, Footer: () => (isLoading ? <LoadingPlaceholder usePadding /> : null),
my: 2, }}
fontWeight: 700, overscan={window.innerHeight * 0.5}
textTransform: 'uppercase', endReached={loadMore}
}} groupCounts={groupCounts}
> groupContent={(index) => (
{dateGroup[0]} <StyledGroupHeader variant="h5" isFirstItem={index === 0}>
</Typography> {groupedUpdates[index][0]}
{dateGroup[1].map(({ item: { chapter, manga }, globalIdx }) => { </StyledGroupHeader>
const download = downloadForChapter(chapter); )}
return ( itemContent={(index) => {
<Card const { chapter, manga } = updateEntries[index];
ref={globalIdx === updateEntries.length - 1 ? lastEntry : undefined} const download = downloadForChapter(chapter);
key={globalIdx}
sx={{ margin: '10px' }} return (
<StyledGroupItemWrapper key={index} isLastItem={index === updateEntries.length - 1}>
<Card>
<CardActionArea
component={Link}
to={{
pathname: `/manga/${chapter.mangaId}/chapter/${chapter.index}`,
state: history.location.state,
}}
> >
<CardActionArea <CardContent
component={Link} sx={{
to={{ display: 'flex',
pathname: `/manga/${chapter.mangaId}/chapter/${chapter.index}`, justifyContent: 'space-between',
state: history.location.state, alignItems: 'center',
padding: 2,
}} }}
> >
<CardContent <Box sx={{ display: 'flex' }}>
sx={{ <Avatar
display: 'flex', variant="rounded"
justifyContent: 'space-between', sx={{
alignItems: 'center', width: 56,
padding: 2, height: 56,
}} flex: '0 0 auto',
> marginRight: 2,
<Box sx={{ display: 'flex' }}> imageRendering: 'pixelated',
<Avatar }}
variant="rounded" src={requestManager.getValidImgUrlFor(manga.thumbnailUrl)}
sx={{ />
width: 56, <Box sx={{ display: 'flex', flexDirection: 'column' }}>
height: 56, <Typography variant="h5" component="h2">
flex: '0 0 auto', {manga.title}
marginRight: 2, </Typography>
imageRendering: 'pixelated', <Typography variant="caption" display="block" gutterBottom>
}} {chapter.name}
src={requestManager.getValidImgUrlFor(manga.thumbnailUrl)} </Typography>
/>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="h5" component="h2">
{manga.title}
</Typography>
<Typography variant="caption" display="block" gutterBottom>
{chapter.name}
</Typography>
</Box>
</Box> </Box>
{download && <DownloadStateIndicator download={download} />} </Box>
{download == null && !chapter.downloaded && ( {download && <DownloadStateIndicator download={download} />}
<IconButton {download == null && !chapter.downloaded && (
onClick={(e) => { <IconButton
e.stopPropagation(); onClick={(e) => {
e.preventDefault(); e.stopPropagation();
downloadChapter(chapter); e.preventDefault();
}} downloadChapter(chapter);
size="large" }}
> size="large"
<DownloadIcon /> >
</IconButton> <DownloadIcon />
)} </IconButton>
</CardContent> )}
</CardActionArea> </CardContent>
</Card> </CardActionArea>
); </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",