Feature/virtualize manga grid (#363)

* Disable ssr by default for "useMediaQuery"

SSR would cause the hook to run once with default values and only after the first render with real values.
This can cause issue on rendering when depending on useMediaQuery results like in 8c129f2e08

* Virtualize manga grids

* Remove SourceMangas load more guard

Was required due to the previous load more trigger logic from the MangaGrid.
Due to using Virtuoso now, load more will be triggered only once when the bottom of the grid was reached and thus won't trigger multiple load more requests

* Remove old Library pagination

Pagination is handled by virtuoso, thus, the previous pagination can be removed

* Increase initial loaded pages to make infinite load work

virtuoso requires enough initial items to be rendered, so that actual virtualization takes effect, for it to fire "endReached"

* Prevent virtuoso grid scrollbar from jumping around

In case the items have a big height difference there is a bug where the scrollbar starts jumping around the moment these new items are rendered

* Restore scroll position

For some reason the UI jumps around when accessing "document.documentElement" during loading more items.
After the items are rendered the loading placeholder gets removed and the previous items jump to the bottom of the viewport.

* Limit manga grid titles to two lines

* Remove "last page" info from MangaGrid

Info is not needed. The only thing the MangaGrid has to do is to trigger a request to load more data
This commit is contained in:
schroda
2023-06-17 16:50:17 +02:00
committed by GitHub
parent 3ad33b0a15
commit 19d27fdf26
7 changed files with 382 additions and 285 deletions

View File

@@ -11,11 +11,10 @@ import Card from '@mui/material/Card';
import CardActionArea from '@mui/material/CardActionArea'; import CardActionArea from '@mui/material/CardActionArea';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Avatar, Box, CardContent, Grid, styled } from '@mui/material'; import { Avatar, Box, CardContent, styled } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IMangaCard } from '@/typings'; import { IMangaCard } from '@/typings';
import requestManager from '@/lib/RequestManager'; import requestManager from '@/lib/RequestManager';
import useLocalStorage from '@/util/useLocalStorage';
import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext'; import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import SpinnerImage from '@/components/util/SpinnerImage'; import SpinnerImage from '@/components/util/SpinnerImage';
@@ -36,9 +35,21 @@ const BottomGradientDoubledDown = styled('div')({
}); });
const MangaTitle = styled(Typography)({ const MangaTitle = styled(Typography)({
lineHeight: '1.5rem',
maxHeight: '3rem',
display: '-webkit-box',
WebkitLineClamp: '2',
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
const GridMangaTitle = styled(MangaTitle)({
width: '100%',
position: 'absolute', position: 'absolute',
bottom: 0, bottom: 0,
padding: '0.5em', margin: '0.5em 0',
padding: '0 0.5em',
fontSize: '1.05rem', fontSize: '1.05rem',
}); });
@@ -55,219 +66,198 @@ const BadgeContainer = styled('div')({
}, },
}); });
const truncateText = (str: string, maxLength: number) => {
const ending = '...';
// trim the string to the maximum length
const trimmedString = str.substr(0, maxLength - ending.length);
if (trimmedString.length < str.length) {
return trimmedString + ending;
}
return str;
};
interface IProps { interface IProps {
manga: IMangaCard; manga: IMangaCard;
gridLayout?: GridLayout; gridLayout?: GridLayout;
dimensions: number;
inLibraryIndicator?: boolean; inLibraryIndicator?: boolean;
} }
const MangaCard = React.forwardRef<HTMLDivElement, IProps>((props: IProps, ref) => { const MangaCard = (props: IProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
manga: { id, title, thumbnailUrl, downloadCount, unreadCount: unread, inLibrary }, manga: { id, title, thumbnailUrl, downloadCount, unreadCount: unread, inLibrary },
gridLayout, gridLayout,
dimensions,
inLibraryIndicator, inLibraryIndicator,
} = props; } = props;
const { const {
options: { showUnreadBadge, showDownloadBadge }, options: { showUnreadBadge, showDownloadBadge },
} = useLibraryOptionsContext(); } = useLibraryOptionsContext();
const [ItemWidth] = useLocalStorage<number>('ItemWidth', 300);
const mangaLinkTo = `/manga/${id}/`; const mangaLinkTo = `/manga/${id}/`;
if (gridLayout !== GridLayout.List) { if (gridLayout !== GridLayout.List) {
const columns = Math.ceil(dimensions / ItemWidth);
const columnsPerItem = 12 / columns;
return ( return (
<Grid item xs={columnsPerItem}> <Link to={mangaLinkTo} style={gridLayout === GridLayout.Comfortable ? { textDecoration: 'none' } : {}}>
<Link to={mangaLinkTo} style={gridLayout === GridLayout.Comfortable ? { textDecoration: 'none' } : {}}> <Box
<Box sx={{
display: 'flex',
flexDirection: 'column',
}}
>
<Card
sx={{ sx={{
// force standard aspect ratio of manga covers
aspectRatio: '225/350',
display: 'flex', display: 'flex',
flexDirection: 'column',
}} }}
> >
<Card <CardActionArea
sx={{ sx={{
// force standard aspect ratio of manga covers position: 'relative',
aspectRatio: '225/350', height: '100%',
display: 'flex',
}} }}
ref={ref}
> >
<CardActionArea <BadgeContainer
sx={{ sx={{
position: 'relative', position: 'absolute',
height: '100%', top: 5,
left: 5,
}} }}
> >
<BadgeContainer {inLibraryIndicator && inLibrary && (
sx={{ <Typography sx={{ backgroundColor: 'primary.dark', zIndex: '1' }}>
position: 'absolute', {t('manga.button.in_library')}
top: 5, </Typography>
left: 5,
}}
>
{inLibraryIndicator && inLibrary && (
<Typography sx={{ backgroundColor: 'primary.dark', zIndex: '1' }}>
{t('manga.button.in_library')}
</Typography>
)}
{showUnreadBadge && unread! > 0 && (
<Typography sx={{ backgroundColor: 'primary.dark' }}>{unread}</Typography>
)}
{showDownloadBadge && downloadCount! > 0 && (
<Typography
sx={{
backgroundColor: 'success.dark',
}}
>
{downloadCount}
</Typography>
)}
</BadgeContainer>
<SpinnerImage
alt={title}
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
imgStyle={
inLibraryIndicator && inLibrary
? {
height: '100%',
width: '100%',
objectFit: 'cover',
filter: 'brightness(0.4)',
}
: {
height: '100%',
width: '100%',
objectFit: 'cover',
}
}
spinnerStyle={{
display: 'grid',
placeItems: 'center',
}}
/>
{gridLayout !== GridLayout.Comfortable && (
<>
<BottomGradient />
<BottomGradientDoubledDown />
<MangaTitle
sx={{
color: 'white',
textShadow: '0px 0px 3px #000000',
}}
title={title}
>
{truncateText(title, 61)}
</MangaTitle>
</>
)} )}
</CardActionArea> {showUnreadBadge && unread! > 0 && (
</Card> <Typography sx={{ backgroundColor: 'primary.dark' }}>{unread}</Typography>
{gridLayout === GridLayout.Comfortable && ( )}
<MangaTitle {showDownloadBadge && downloadCount! > 0 && (
sx={{ <Typography
position: 'relative', sx={{
color: 'text.primary', backgroundColor: 'success.dark',
}}
>
{downloadCount}
</Typography>
)}
</BadgeContainer>
<SpinnerImage
alt={title}
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
imgStyle={
inLibraryIndicator && inLibrary
? {
height: '100%',
width: '100%',
objectFit: 'cover',
filter: 'brightness(0.4)',
}
: {
height: '100%',
width: '100%',
objectFit: 'cover',
}
}
spinnerStyle={{
display: 'grid',
placeItems: 'center',
}} }}
title={title} />
> {gridLayout !== GridLayout.Comfortable && (
{truncateText(title, 61)} <>
</MangaTitle> <BottomGradient />
)} <BottomGradientDoubledDown />
</Box> <GridMangaTitle
</Link> sx={{
</Grid> color: 'white',
textShadow: '0px 0px 3px #000000',
}}
title={title}
>
{title}
</GridMangaTitle>
</>
)}
</CardActionArea>
</Card>
{gridLayout === GridLayout.Comfortable && (
<GridMangaTitle
sx={{
position: 'relative',
color: 'text.primary',
height: '3rem',
}}
title={title}
>
{title}
</GridMangaTitle>
)}
</Box>
</Link>
); );
} }
return ( return (
<Grid item xs={12}> <Card>
<Card ref={ref}> <CardActionArea component={Link} to={mangaLinkTo}>
<CardActionArea component={Link} to={mangaLinkTo}> <CardContent
<CardContent sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: 2,
position: 'relative',
}}
>
<Avatar
variant="rounded"
sx={
inLibraryIndicator && inLibrary
? {
width: 56,
height: 56,
flex: '0 0 auto',
marginRight: 2,
imageRendering: 'pixelated',
filter: 'brightness(0.4)',
}
: {
width: 56,
height: 56,
flex: '0 0 auto',
marginRight: 2,
imageRendering: 'pixelated',
}
}
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
/>
<Box
sx={{ sx={{
display: 'flex', display: 'flex',
justifyContent: 'space-between', flexDirection: 'row',
alignItems: 'center', flexGrow: 1,
padding: 2, width: 'min-content',
position: 'relative',
}} }}
> >
<Avatar <MangaTitle variant="h5" title={title}>
variant="rounded" {title}
sx={ </MangaTitle>
inLibraryIndicator && inLibrary </Box>
? { <BadgeContainer>
width: 56, {inLibraryIndicator && inLibrary && (
height: 56, <Typography sx={{ backgroundColor: 'primary.dark' }}>
flex: '0 0 auto', {t('manga.button.in_library')}
marginRight: 2,
imageRendering: 'pixelated',
filter: 'brightness(0.4)',
}
: {
width: 56,
height: 56,
flex: '0 0 auto',
marginRight: 2,
imageRendering: 'pixelated',
}
}
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
/>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
flexGrow: 1,
width: 'min-content',
}}
>
<Typography variant="h5" component="h2" title={title}>
{truncateText(title, 61)}
</Typography> </Typography>
</Box> )}
<BadgeContainer> {showUnreadBadge && unread! > 0 && (
{inLibraryIndicator && inLibrary && ( <Typography sx={{ backgroundColor: 'primary.dark' }}>{unread}</Typography>
<Typography sx={{ backgroundColor: 'primary.dark' }}> )}
{t('manga.button.in_library')} {showDownloadBadge && downloadCount! > 0 && (
</Typography> <Typography
)} sx={{
{showUnreadBadge && unread! > 0 && ( backgroundColor: 'success.dark',
<Typography sx={{ backgroundColor: 'primary.dark' }}>{unread}</Typography> }}
)} >
{showDownloadBadge && downloadCount! > 0 && ( {downloadCount}
<Typography </Typography>
sx={{ )}
backgroundColor: 'success.dark', </BadgeContainer>
}} </CardContent>
> </CardActionArea>
{downloadCount} </Card>
</Typography>
)}
</BadgeContainer>
</CardContent>
</CardActionArea>
</Card>
</Grid>
); );
}); };
export default MangaCard; export default MangaCard;

View File

@@ -6,14 +6,145 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import Grid from '@mui/material/Grid'; import Grid, { GridTypeMap } from '@mui/material/Grid';
import { Typography, Box } from '@mui/material'; import { Box, Typography } from '@mui/material';
import { GridItemProps, VirtuosoGrid, VirtuosoGridHandle } from 'react-virtuoso';
import { useNavigate, useLocation } from 'react-router-dom';
import { IMangaCard } from '@/typings'; import { IMangaCard } from '@/typings';
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 MangaCard from '@/components/MangaCard'; import MangaCard from '@/components/MangaCard';
import { GridLayout } from '@/components/context/LibraryOptionsContext'; import { GridLayout } from '@/components/context/LibraryOptionsContext';
import useLocalStorage from '@/util/useLocalStorage';
const GridContainer = React.forwardRef<HTMLDivElement, GridTypeMap['props']>(({ children, ...props }, ref) => (
<Grid {...props} ref={ref} container sx={{ paddingLeft: '5px', paddingRight: '13px' }}>
{children}
</Grid>
));
const GridItemContainerWithDimension =
(dimensions: number, itemWidth: number, gridLayout?: GridLayout, maxColumns: number = 12) =>
({ children, ...itemProps }: GridTypeMap['props'] & Partial<GridItemProps>) => {
const itemsPerRow = Math.ceil(dimensions / itemWidth);
const columnsPerItem = gridLayout === GridLayout.List ? maxColumns : maxColumns / itemsPerRow;
return (
<Grid {...itemProps} item xs={columnsPerItem} sx={{ paddingTop: '8px', paddingLeft: '8px' }}>
{children}
</Grid>
);
};
const createMangaCard = (manga: IMangaCard, gridLayout?: GridLayout, inLibraryIndicator?: boolean) => (
<MangaCard key={manga.id} manga={manga} gridLayout={gridLayout} inLibraryIndicator={inLibraryIndicator} />
);
type DefaultGridProps = {
isLoading: boolean;
mangas: IMangaCard[];
inLibraryIndicator?: boolean;
GridItemContainer: (props: GridTypeMap['props'] & Partial<GridItemProps>) => JSX.Element;
gridLayout?: GridLayout;
};
const HorizontalGrid = ({ isLoading, mangas, inLibraryIndicator, GridItemContainer, gridLayout }: DefaultGridProps) => (
<Grid
container
spacing={1}
style={{
margin: 0,
width: '100%',
padding: '5px',
overflowX: 'scroll',
display: '-webkit-inline-box',
flexWrap: 'nowrap',
}}
>
{isLoading ? (
<LoadingPlaceholder />
) : (
mangas.map((manga) => (
<GridItemContainer key={manga.id}>
{createMangaCard(manga, gridLayout, inLibraryIndicator)}
</GridItemContainer>
))
)}
</Grid>
);
const VerticalGrid = ({
isLoading,
mangas,
inLibraryIndicator,
GridItemContainer,
gridLayout,
hasNextPage,
loadMore,
}: DefaultGridProps & {
hasNextPage: boolean;
loadMore: () => void;
}) => {
const [restoredScrollPosition, setRestoredScrollPosition] = useState(mangas.length === 0);
const location = useLocation<{ lastScrollPosition?: number }>();
const navigate = useNavigate();
const virtuoso = useRef<VirtuosoGridHandle>(null);
const { lastScrollPosition = 0 } = location.state ?? {};
useEffect(() => {
const updateLastScrollPosition = () => {
if (!restoredScrollPosition) {
return;
}
navigate(
{ pathname: '', search: location.search },
{ replace: true, state: { ...location.state, lastScrollPosition: window.scrollY } },
);
};
window.addEventListener('scroll', updateLastScrollPosition, true);
window.addEventListener('resize', updateLastScrollPosition, true);
return () => {
window.removeEventListener('scroll', updateLastScrollPosition, true);
window.removeEventListener('resize', updateLastScrollPosition, true);
};
}, [restoredScrollPosition, location.state, location.search]);
useEffect(() => {
const haveItemsRendered = document.documentElement.offsetHeight >= lastScrollPosition;
const restoreScrollPosition = !restoredScrollPosition && haveItemsRendered && virtuoso.current;
if (!restoreScrollPosition) {
return;
}
virtuoso.current.scrollTo({ top: lastScrollPosition });
setRestoredScrollPosition(true);
}, [document.documentElement.offsetHeight, virtuoso.current]);
return (
<>
<VirtuosoGrid
ref={virtuoso}
useWindowScroll
overscan={window.innerHeight * 0.25}
totalCount={mangas.length}
components={{
List: GridContainer,
Item: GridItemContainer,
}}
endReached={() => loadMore()}
itemContent={(index) => createMangaCard(mangas[index], gridLayout, inLibraryIndicator)}
/>
{/* render div to prevent UI jumping around when showing/hiding loading placeholder */
/* eslint-disable-next-line no-nested-ternary */}
{isLoading ? <LoadingPlaceholder /> : hasNextPage ? <div style={{ height: '75px' }} /> : null}
</>
);
};
export interface IMangaGridProps { export interface IMangaGridProps {
mangas: IMangaCard[]; mangas: IMangaCard[];
@@ -21,8 +152,7 @@ export interface IMangaGridProps {
message?: string; message?: string;
messageExtra?: JSX.Element; messageExtra?: JSX.Element;
hasNextPage: boolean; hasNextPage: boolean;
lastPageNum: number; loadMore: () => void;
setLastPageNum: (lastPageNum: number) => void;
gridLayout?: GridLayout; gridLayout?: GridLayout;
horizontal?: boolean | undefined; horizontal?: boolean | undefined;
noFaces?: boolean | undefined; noFaces?: boolean | undefined;
@@ -36,53 +166,47 @@ const MangaGrid: React.FC<IMangaGridProps> = (props) => {
message, message,
messageExtra, messageExtra,
hasNextPage, hasNextPage,
lastPageNum, loadMore,
setLastPageNum,
gridLayout, gridLayout,
horizontal, horizontal,
noFaces, noFaces,
inLibraryIndicator, inLibraryIndicator,
} = props; } = props;
let mapped;
const lastManga = useRef<HTMLDivElement>(null);
const scrollHandler = () => {
if (lastManga.current) {
const rect = lastManga.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, mangas]);
const [dimensions, setDimensions] = useState(1);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [gridItemWidth] = useLocalStorage<number>('ItemWidth', 300);
const gridRef = useRef<HTMLDivElement>(null); const gridRef = useRef<HTMLDivElement>(null);
const GridItemContainer = useMemo(
() => GridItemContainerWithDimension(dimensions.width, gridItemWidth, gridLayout),
[dimensions, gridItemWidth, gridLayout],
);
const TestDimensions = () => { const updateGridWidth = () => {
setDimensions(gridRef.current ? gridRef.current.offsetWidth : 0); setDimensions({
width: gridRef.current?.offsetWidth ?? 0,
height: gridRef.current?.offsetHeight ?? 0,
});
}; };
useLayoutEffect(TestDimensions, []); useLayoutEffect(updateGridWidth, []);
let movementTimer: NodeJS.Timeout; useEffect(() => {
let movementTimer: NodeJS.Timeout;
window.addEventListener('resize', () => { const onResize = () => {
clearInterval(movementTimer); clearInterval(movementTimer);
movementTimer = setTimeout(TestDimensions, 100); movementTimer = setTimeout(updateGridWidth, 100);
}); };
if (mangas.length === 0) { window.addEventListener('resize', onResize);
if (isLoading) {
mapped = <LoadingPlaceholder />; return () => window.removeEventListener('resize', onResize);
} else { }, []);
mapped = noFaces ? (
const hasNoItems = !isLoading && mangas.length === 0;
if (hasNoItems) {
if (noFaces) {
return (
<Box <Box
sx={{ sx={{
margin: 'auto', margin: 'auto',
@@ -91,47 +215,38 @@ const MangaGrid: React.FC<IMangaGridProps> = (props) => {
<Typography variant="h5">{message}</Typography> <Typography variant="h5">{message}</Typography>
{messageExtra} {messageExtra}
</Box> </Box>
) : (
<EmptyView message={message!} messageExtra={messageExtra} />
); );
} }
} else {
mapped = mangas.map((it, idx) => ( return <EmptyView message={message!} messageExtra={messageExtra} />;
<MangaCard
key={it.id}
manga={it}
ref={idx === mangas.length - 1 ? lastManga : undefined}
gridLayout={gridLayout}
dimensions={dimensions}
inLibraryIndicator={inLibraryIndicator}
/>
));
} }
return ( return (
<div ref={gridRef}> <div
<Grid ref={gridRef}
container style={{
spacing={1} overflow: 'hidden',
style={ paddingBottom: '13px',
horizontal }}
? { >
margin: 0, {horizontal ? (
width: '100%', <HorizontalGrid
padding: '5px', isLoading={isLoading}
overflowX: 'scroll', mangas={mangas}
display: '-webkit-inline-box', inLibraryIndicator={inLibraryIndicator}
flexWrap: 'nowrap', GridItemContainer={GridItemContainer}
} gridLayout={gridLayout}
: { />
margin: 0, ) : (
width: '100%', <VerticalGrid
padding: '5px', isLoading={isLoading}
} mangas={mangas}
} GridItemContainer={GridItemContainer}
> hasNextPage={hasNextPage}
{mapped} loadMore={loadMore}
</Grid> gridLayout={gridLayout}
/>
)}
</div> </div>
); );
}; };

View File

@@ -6,9 +6,8 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo } from 'react';
import { StringParam, useQueryParam } from 'use-query-params'; import { StringParam, useQueryParam } from 'use-query-params';
import { useMediaQuery, useTheme } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IMangaCard, LibrarySortMode, NullAndUndefined } from '@/typings'; import { IMangaCard, LibrarySortMode, NullAndUndefined } from '@/typings';
import { useSearchSettings } from '@/util/searchSettings'; import { useSearchSettings } from '@/util/searchSettings';
@@ -120,11 +119,6 @@ const LibraryMangaGrid: React.FC<LibraryMangaGridProps & { lastLibraryUpdate: nu
const [query] = useQueryParam('query', StringParam); const [query] = useQueryParam('query', StringParam);
const { options } = useLibraryOptionsContext(); const { options } = useLibraryOptionsContext();
const { unread, downloaded } = options; const { unread, downloaded } = options;
const totalPages = Math.trunc((mangas ?? []).length / 10);
const theme = useTheme();
const isLargeScreen = useMediaQuery(theme.breakpoints.up('sm'), { noSsr: true });
const defaultPageNumber = isLargeScreen ? 4 : 1;
const [lastPageNum, setLastPageNum] = useState<number>(defaultPageNumber);
const { settings } = useSearchSettings(); const { settings } = useSearchSettings();
const filteredMangas = useMemo( const filteredMangas = useMemo(
@@ -135,23 +129,20 @@ const LibraryMangaGrid: React.FC<LibraryMangaGridProps & { lastLibraryUpdate: nu
() => sortManga(filteredMangas, options.sorts, options.sortDesc), () => sortManga(filteredMangas, options.sorts, options.sortDesc),
[filteredMangas, lastLibraryUpdate, options.sorts, options.sortDesc], [filteredMangas, lastLibraryUpdate, options.sorts, options.sortDesc],
); );
const filteredPaginatedMangas = useMemo(() => sortedMangas.slice(0, lastPageNum * 10), [sortedMangas, lastPageNum]);
const showFilteredOutMessage = const showFilteredOutMessage =
(unread != null || downloaded != null || query) && filteredMangas.length === 0 && mangas.length > 0; (unread != null || downloaded != null || query) && filteredMangas.length === 0 && mangas.length > 0;
useEffect(() => { useEffect(() => {
setLastPageNum(defaultPageNumber);
window.scrollTo(0, 0); window.scrollTo(0, 0);
}, [filteredMangas]); }, [filteredMangas]);
return ( return (
<MangaGrid <MangaGrid
mangas={filteredPaginatedMangas} mangas={sortedMangas}
isLoading={isLoading} isLoading={isLoading}
hasNextPage={lastPageNum < totalPages} hasNextPage={false}
lastPageNum={lastPageNum} loadMore={() => undefined}
setLastPageNum={setLastPageNum}
message={showFilteredOutMessage ? t('library.error.label.no_matches') : message} message={showFilteredOutMessage ? t('library.error.label.no_matches') : message}
gridLayout={options.gridLayout} gridLayout={options.gridLayout}
/> />

View File

@@ -17,7 +17,7 @@ function filterManga(mangas: IMangaCard[]): IMangaCard[] {
export default function SourceMangaGrid(props: IMangaGridProps) { export default function SourceMangaGrid(props: IMangaGridProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { mangas, isLoading, hasNextPage, lastPageNum, setLastPageNum, message, messageExtra, gridLayout } = props; const { mangas, isLoading, hasNextPage, loadMore, message, messageExtra, gridLayout } = props;
const filteredManga = filterManga(mangas); const filteredManga = filterManga(mangas);
const showFilteredOutMessage = filteredManga.length === 0 && mangas.length > 0; const showFilteredOutMessage = filteredManga.length === 0 && mangas.length > 0;
@@ -27,8 +27,7 @@ export default function SourceMangaGrid(props: IMangaGridProps) {
mangas={filteredManga} mangas={filteredManga}
isLoading={isLoading} isLoading={isLoading}
hasNextPage={hasNextPage} hasNextPage={hasNextPage}
lastPageNum={lastPageNum} loadMore={loadMore}
setLastPageNum={setLastPageNum}
message={showFilteredOutMessage ? t('manga.error.label.no_matches') : message} message={showFilteredOutMessage ? t('manga.error.label.no_matches') : message}
messageExtra={messageExtra} messageExtra={messageExtra}
gridLayout={gridLayout} gridLayout={gridLayout}

View File

@@ -101,8 +101,6 @@ const SourceSearchPreview = React.memo(
const { id, displayName, lang } = source; const { id, displayName, lang } = source;
const { const {
data: searchResult, data: searchResult,
size,
setSize,
isLoading, isLoading,
error, error,
abortRequest, abortRequest,
@@ -149,8 +147,7 @@ const SourceSearchPreview = React.memo(
mangas={mangas} mangas={mangas}
isLoading={isLoading} isLoading={isLoading}
hasNextPage={false} hasNextPage={false}
lastPageNum={size} loadMore={() => undefined}
setLastPageNum={setSize}
horizontal horizontal
noFaces noFaces
message={errorMessage} message={errorMessage}

View File

@@ -13,7 +13,7 @@ import SettingsIcon from '@mui/icons-material/Settings';
import { useQueryParam, StringParam } from 'use-query-params'; import { useQueryParam, StringParam } from 'use-query-params';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Link from '@mui/material/Link'; import Link from '@mui/material/Link';
import { Box, Button, styled } from '@mui/material'; import { Box, Button, styled, useTheme, useMediaQuery } from '@mui/material';
import FavoriteIcon from '@mui/icons-material/Favorite'; import FavoriteIcon from '@mui/icons-material/Favorite';
import NewReleasesIcon from '@mui/icons-material/NewReleases'; import NewReleasesIcon from '@mui/icons-material/NewReleases';
import FilterListIcon from '@mui/icons-material/FilterList'; import FilterListIcon from '@mui/icons-material/FilterList';
@@ -48,13 +48,13 @@ const ContentTypeButton = styled(Button)(() => ({
const StyledGridWrapper = styled(Box, { shouldForwardProp: (prop) => prop !== 'hasContent' })<{ hasContent: boolean }>( const StyledGridWrapper = styled(Box, { shouldForwardProp: (prop) => prop !== 'hasContent' })<{ hasContent: boolean }>(
({ theme, hasContent }) => ({ ({ theme, hasContent }) => ({
// 62.5px ContentTypeMenu height (- padding of grid + grid item) // 62.5px ContentTypeMenu height (- padding of grid + grid item)
marginTop: `calc(62.5px ${hasContent ? '- 13px' : ''})`, marginTop: `calc(62.5px ${hasContent ? '- 8px' : ''})`,
// header height - ContentTypeMenu height // header height - ContentTypeMenu height
minHeight: 'calc(100vh - 64px - 62.5px)', minHeight: 'calc(100vh - 64px - 62.5px)',
position: 'relative', position: 'relative',
[theme.breakpoints.down('sm')]: { [theme.breakpoints.down('sm')]: {
// 62.5px ContentTypeMenu - 8px margin diff header height (56px) (- padding of grid + grid item) // 62.5px ContentTypeMenu - 8px margin diff header height (56px) (- padding of grid item)
marginTop: `calc(62.5px - 8px ${hasContent ? '- 13px' : ''})`, marginTop: `calc(62.5px - 8px ${hasContent ? '- 8px' : ''})`,
// header height (+ 8px margin) - footer height - ContentTypeMenu height // header height (+ 8px margin) - footer height - ContentTypeMenu height
minHeight: 'calc(100vh - 64px - 64px - 62.5px)', minHeight: 'calc(100vh - 64px - 64px - 62.5px)',
}, },
@@ -106,17 +106,18 @@ const useSourceManga = (
contentType: SourceContentType, contentType: SourceContentType,
searchTerm: string | null | undefined, searchTerm: string | null | undefined,
filters: IPos[], filters: IPos[],
initialPages = 1,
): SourceMangaResponse => { ): SourceMangaResponse => {
let result: AbortableSWRInfiniteResponse<PaginatedMangaList>; let result: AbortableSWRInfiniteResponse<PaginatedMangaList>;
switch (contentType) { switch (contentType) {
case SourceContentType.POPULAR: case SourceContentType.POPULAR:
result = requestManager.useGetSourcePopularMangas(sourceId, 1); result = requestManager.useGetSourcePopularMangas(sourceId, initialPages);
break; break;
case SourceContentType.LATEST: case SourceContentType.LATEST:
result = requestManager.useGetSourceLatestMangas(sourceId, 1); result = requestManager.useGetSourceLatestMangas(sourceId, initialPages);
break; break;
case SourceContentType.SEARCH: case SourceContentType.SEARCH:
result = requestManager.useSourceQuickSearch(sourceId, searchTerm ?? '', [], 1); result = requestManager.useSourceQuickSearch(sourceId, searchTerm ?? '', [], initialPages);
break; break;
case SourceContentType.FILTER: case SourceContentType.FILTER:
result = requestManager.useSourceQuickSearch( result = requestManager.useSourceQuickSearch(
@@ -138,7 +139,7 @@ const useSourceManga = (
return filter; return filter;
}), }),
1, initialPages,
{ disableCache: true }, { disableCache: true },
); );
break; break;
@@ -160,6 +161,8 @@ const useSourceManga = (
export default function SourceMangas() { export default function SourceMangas() {
const { t } = useTranslation(); const { t } = useTranslation();
const { setTitle, setAction } = useContext(NavbarContext); const { setTitle, setAction } = useContext(NavbarContext);
const theme = useTheme();
const isLargeScreen = useMediaQuery(theme.breakpoints.up('sm'));
const { sourceId } = useParams<{ sourceId: string }>(); const { sourceId } = useParams<{ sourceId: string }>();
@@ -183,7 +186,7 @@ export default function SourceMangas() {
setSize: setPages, setSize: setPages,
mutate: refreshData, mutate: refreshData,
abortRequest, abortRequest,
} = useSourceManga(sourceId, contentType, searchTerm, filtersToApply); } = useSourceManga(sourceId, contentType, searchTerm, filtersToApply, isLargeScreen ? 2 : 1);
const { data: filters = [], mutate: mutateFilters } = requestManager.useGetSourceFilters(sourceId); const { data: filters = [], mutate: mutateFilters } = requestManager.useGetSourceFilters(sourceId);
const { data: source } = requestManager.useGetSource(sourceId); const { data: source } = requestManager.useGetSource(sourceId);
const [triggerDataRefresh, setTriggerDataRefresh] = useState(false); const [triggerDataRefresh, setTriggerDataRefresh] = useState(false);
@@ -222,15 +225,13 @@ export default function SourceMangas() {
updateContentType(currentLocationContentType, false); updateContentType(currentLocationContentType, false);
} }
let wasLoadMoreTriggered = false; const loadMore = useCallback(() => {
const setLastPageNum = useCallback(() => { if (!hasNextPage) {
if (!hasNextPage || wasLoadMoreTriggered) {
return; return;
} }
wasLoadMoreTriggered = true;
setPages(lastPageNum + 1); setPages(lastPageNum + 1);
}, [setPages, hasNextPage, lastPageNum]); }, [setPages, lastPageNum, hasNextPage]);
const resetFilters = useCallback(async () => { const resetFilters = useCallback(async () => {
setDialogFiltersToApply([]); setDialogFiltersToApply([]);
@@ -325,8 +326,7 @@ export default function SourceMangas() {
<SourceMangaGrid <SourceMangaGrid
mangas={mangas} mangas={mangas}
hasNextPage={hasNextPage} hasNextPage={hasNextPage}
lastPageNum={lastPageNum} loadMore={loadMore}
setLastPageNum={setLastPageNum}
message={message} message={message}
messageExtra={messageExtra} messageExtra={messageExtra}
isLoading={isLoading} isLoading={isLoading}

View File

@@ -37,6 +37,11 @@ const createTheme = (dark?: boolean) => {
}, },
}, },
components: { components: {
MuiUseMediaQuery: {
defaultProps: {
noSsr: true,
},
},
MuiCssBaseline: { MuiCssBaseline: {
styleOverrides: ` styleOverrides: `
*::-webkit-scrollbar { *::-webkit-scrollbar {