diff --git a/src/components/MangaCard.tsx b/src/components/MangaCard.tsx index f718a442..9fa2052a 100644 --- a/src/components/MangaCard.tsx +++ b/src/components/MangaCard.tsx @@ -11,11 +11,10 @@ import Card from '@mui/material/Card'; import CardActionArea from '@mui/material/CardActionArea'; import Typography from '@mui/material/Typography'; 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 { IMangaCard } from '@/typings'; import requestManager from '@/lib/RequestManager'; -import useLocalStorage from '@/util/useLocalStorage'; import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext'; import SpinnerImage from '@/components/util/SpinnerImage'; @@ -36,9 +35,21 @@ const BottomGradientDoubledDown = styled('div')({ }); 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', bottom: 0, - padding: '0.5em', + margin: '0.5em 0', + padding: '0 0.5em', 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 { manga: IMangaCard; gridLayout?: GridLayout; - dimensions: number; inLibraryIndicator?: boolean; } -const MangaCard = React.forwardRef((props: IProps, ref) => { +const MangaCard = (props: IProps) => { const { t } = useTranslation(); const { manga: { id, title, thumbnailUrl, downloadCount, unreadCount: unread, inLibrary }, gridLayout, - dimensions, inLibraryIndicator, } = props; const { options: { showUnreadBadge, showDownloadBadge }, } = useLibraryOptionsContext(); - const [ItemWidth] = useLocalStorage('ItemWidth', 300); - const mangaLinkTo = `/manga/${id}/`; if (gridLayout !== GridLayout.List) { - const columns = Math.ceil(dimensions / ItemWidth); - const columnsPerItem = 12 / columns; return ( - - - + + - - - - {inLibraryIndicator && inLibrary && ( - - {t('manga.button.in_library')} - - )} - {showUnreadBadge && unread! > 0 && ( - {unread} - )} - {showDownloadBadge && downloadCount! > 0 && ( - - {downloadCount} - - )} - - - {gridLayout !== GridLayout.Comfortable && ( - <> - - - - {truncateText(title, 61)} - - + {inLibraryIndicator && inLibrary && ( + + {t('manga.button.in_library')} + )} - - - {gridLayout === GridLayout.Comfortable && ( - 0 && ( + {unread} + )} + {showDownloadBadge && downloadCount! > 0 && ( + + {downloadCount} + + )} + + - {truncateText(title, 61)} - - )} - - - + /> + {gridLayout !== GridLayout.Comfortable && ( + <> + + + + {title} + + + )} + + + {gridLayout === GridLayout.Comfortable && ( + + {title} + + )} + + ); } return ( - - - - + + + + - - - - {truncateText(title, 61)} + + {title} + + + + {inLibraryIndicator && inLibrary && ( + + {t('manga.button.in_library')} - - - {inLibraryIndicator && inLibrary && ( - - {t('manga.button.in_library')} - - )} - {showUnreadBadge && unread! > 0 && ( - {unread} - )} - {showDownloadBadge && downloadCount! > 0 && ( - - {downloadCount} - - )} - - - - - + )} + {showUnreadBadge && unread! > 0 && ( + {unread} + )} + {showDownloadBadge && downloadCount! > 0 && ( + + {downloadCount} + + )} + + + + ); -}); +}; export default MangaCard; diff --git a/src/components/MangaGrid.tsx b/src/components/MangaGrid.tsx index a2af3c87..3ba4cab2 100644 --- a/src/components/MangaGrid.tsx +++ b/src/components/MangaGrid.tsx @@ -6,14 +6,145 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import Grid from '@mui/material/Grid'; -import { Typography, Box } from '@mui/material'; +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import Grid, { GridTypeMap } from '@mui/material/Grid'; +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 EmptyView from '@/components/util/EmptyView'; import LoadingPlaceholder from '@/components/util/LoadingPlaceholder'; import MangaCard from '@/components/MangaCard'; import { GridLayout } from '@/components/context/LibraryOptionsContext'; +import useLocalStorage from '@/util/useLocalStorage'; + +const GridContainer = React.forwardRef(({ children, ...props }, ref) => ( + + {children} + +)); + +const GridItemContainerWithDimension = + (dimensions: number, itemWidth: number, gridLayout?: GridLayout, maxColumns: number = 12) => + ({ children, ...itemProps }: GridTypeMap['props'] & Partial) => { + const itemsPerRow = Math.ceil(dimensions / itemWidth); + const columnsPerItem = gridLayout === GridLayout.List ? maxColumns : maxColumns / itemsPerRow; + + return ( + + {children} + + ); + }; + +const createMangaCard = (manga: IMangaCard, gridLayout?: GridLayout, inLibraryIndicator?: boolean) => ( + +); + +type DefaultGridProps = { + isLoading: boolean; + mangas: IMangaCard[]; + inLibraryIndicator?: boolean; + GridItemContainer: (props: GridTypeMap['props'] & Partial) => JSX.Element; + gridLayout?: GridLayout; +}; + +const HorizontalGrid = ({ isLoading, mangas, inLibraryIndicator, GridItemContainer, gridLayout }: DefaultGridProps) => ( + + {isLoading ? ( + + ) : ( + mangas.map((manga) => ( + + {createMangaCard(manga, gridLayout, inLibraryIndicator)} + + )) + )} + +); + +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(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 ( + <> + 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 ? : hasNextPage ?
: null} + + ); +}; export interface IMangaGridProps { mangas: IMangaCard[]; @@ -21,8 +152,7 @@ export interface IMangaGridProps { message?: string; messageExtra?: JSX.Element; hasNextPage: boolean; - lastPageNum: number; - setLastPageNum: (lastPageNum: number) => void; + loadMore: () => void; gridLayout?: GridLayout; horizontal?: boolean | undefined; noFaces?: boolean | undefined; @@ -36,53 +166,47 @@ const MangaGrid: React.FC = (props) => { message, messageExtra, hasNextPage, - lastPageNum, - setLastPageNum, + loadMore, gridLayout, horizontal, noFaces, inLibraryIndicator, } = props; - let mapped; - const lastManga = useRef(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('ItemWidth', 300); const gridRef = useRef(null); + const GridItemContainer = useMemo( + () => GridItemContainerWithDimension(dimensions.width, gridItemWidth, gridLayout), + [dimensions, gridItemWidth, gridLayout], + ); - const TestDimensions = () => { - setDimensions(gridRef.current ? gridRef.current.offsetWidth : 0); + const updateGridWidth = () => { + 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', () => { - clearInterval(movementTimer); - movementTimer = setTimeout(TestDimensions, 100); - }); + const onResize = () => { + clearInterval(movementTimer); + movementTimer = setTimeout(updateGridWidth, 100); + }; - if (mangas.length === 0) { - if (isLoading) { - mapped = ; - } else { - mapped = noFaces ? ( + window.addEventListener('resize', onResize); + + return () => window.removeEventListener('resize', onResize); + }, []); + + const hasNoItems = !isLoading && mangas.length === 0; + if (hasNoItems) { + if (noFaces) { + return ( = (props) => { {message} {messageExtra} - ) : ( - ); } - } else { - mapped = mangas.map((it, idx) => ( - - )); + + return ; } return ( -
- - {mapped} - +
+ {horizontal ? ( + + ) : ( + + )}
); }; diff --git a/src/components/library/LibraryMangaGrid.tsx b/src/components/library/LibraryMangaGrid.tsx index 6d7be526..c3250e05 100644 --- a/src/components/library/LibraryMangaGrid.tsx +++ b/src/components/library/LibraryMangaGrid.tsx @@ -6,9 +6,8 @@ * 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 { useMediaQuery, useTheme } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { IMangaCard, LibrarySortMode, NullAndUndefined } from '@/typings'; import { useSearchSettings } from '@/util/searchSettings'; @@ -120,11 +119,6 @@ const LibraryMangaGrid: React.FC(defaultPageNumber); const { settings } = useSearchSettings(); const filteredMangas = useMemo( @@ -135,23 +129,20 @@ const LibraryMangaGrid: React.FC sortManga(filteredMangas, options.sorts, options.sortDesc), [filteredMangas, lastLibraryUpdate, options.sorts, options.sortDesc], ); - const filteredPaginatedMangas = useMemo(() => sortedMangas.slice(0, lastPageNum * 10), [sortedMangas, lastPageNum]); const showFilteredOutMessage = (unread != null || downloaded != null || query) && filteredMangas.length === 0 && mangas.length > 0; useEffect(() => { - setLastPageNum(defaultPageNumber); window.scrollTo(0, 0); }, [filteredMangas]); return ( undefined} message={showFilteredOutMessage ? t('library.error.label.no_matches') : message} gridLayout={options.gridLayout} /> diff --git a/src/components/source/SourceMangaGrid.tsx b/src/components/source/SourceMangaGrid.tsx index aa0e3193..194205a9 100644 --- a/src/components/source/SourceMangaGrid.tsx +++ b/src/components/source/SourceMangaGrid.tsx @@ -17,7 +17,7 @@ function filterManga(mangas: IMangaCard[]): IMangaCard[] { export default function SourceMangaGrid(props: IMangaGridProps) { 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 showFilteredOutMessage = filteredManga.length === 0 && mangas.length > 0; @@ -27,8 +27,7 @@ export default function SourceMangaGrid(props: IMangaGridProps) { mangas={filteredManga} isLoading={isLoading} hasNextPage={hasNextPage} - lastPageNum={lastPageNum} - setLastPageNum={setLastPageNum} + loadMore={loadMore} message={showFilteredOutMessage ? t('manga.error.label.no_matches') : message} messageExtra={messageExtra} gridLayout={gridLayout} diff --git a/src/screens/SearchAll.tsx b/src/screens/SearchAll.tsx index 09701bd2..7f88c988 100644 --- a/src/screens/SearchAll.tsx +++ b/src/screens/SearchAll.tsx @@ -101,8 +101,6 @@ const SourceSearchPreview = React.memo( const { id, displayName, lang } = source; const { data: searchResult, - size, - setSize, isLoading, error, abortRequest, @@ -149,8 +147,7 @@ const SourceSearchPreview = React.memo( mangas={mangas} isLoading={isLoading} hasNextPage={false} - lastPageNum={size} - setLastPageNum={setSize} + loadMore={() => undefined} horizontal noFaces message={errorMessage} diff --git a/src/screens/SourceMangas.tsx b/src/screens/SourceMangas.tsx index 78773ffa..355e746b 100644 --- a/src/screens/SourceMangas.tsx +++ b/src/screens/SourceMangas.tsx @@ -13,7 +13,7 @@ import SettingsIcon from '@mui/icons-material/Settings'; import { useQueryParam, StringParam } from 'use-query-params'; import { useTranslation } from 'react-i18next'; 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 NewReleasesIcon from '@mui/icons-material/NewReleases'; 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 }>( ({ theme, hasContent }) => ({ // 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 minHeight: 'calc(100vh - 64px - 62.5px)', position: 'relative', [theme.breakpoints.down('sm')]: { - // 62.5px ContentTypeMenu - 8px margin diff header height (56px) (- padding of grid + grid item) - marginTop: `calc(62.5px - 8px ${hasContent ? '- 13px' : ''})`, + // 62.5px ContentTypeMenu - 8px margin diff header height (56px) (- padding of grid item) + marginTop: `calc(62.5px - 8px ${hasContent ? '- 8px' : ''})`, // header height (+ 8px margin) - footer height - ContentTypeMenu height minHeight: 'calc(100vh - 64px - 64px - 62.5px)', }, @@ -106,17 +106,18 @@ const useSourceManga = ( contentType: SourceContentType, searchTerm: string | null | undefined, filters: IPos[], + initialPages = 1, ): SourceMangaResponse => { let result: AbortableSWRInfiniteResponse; switch (contentType) { case SourceContentType.POPULAR: - result = requestManager.useGetSourcePopularMangas(sourceId, 1); + result = requestManager.useGetSourcePopularMangas(sourceId, initialPages); break; case SourceContentType.LATEST: - result = requestManager.useGetSourceLatestMangas(sourceId, 1); + result = requestManager.useGetSourceLatestMangas(sourceId, initialPages); break; case SourceContentType.SEARCH: - result = requestManager.useSourceQuickSearch(sourceId, searchTerm ?? '', [], 1); + result = requestManager.useSourceQuickSearch(sourceId, searchTerm ?? '', [], initialPages); break; case SourceContentType.FILTER: result = requestManager.useSourceQuickSearch( @@ -138,7 +139,7 @@ const useSourceManga = ( return filter; }), - 1, + initialPages, { disableCache: true }, ); break; @@ -160,6 +161,8 @@ const useSourceManga = ( export default function SourceMangas() { const { t } = useTranslation(); const { setTitle, setAction } = useContext(NavbarContext); + const theme = useTheme(); + const isLargeScreen = useMediaQuery(theme.breakpoints.up('sm')); const { sourceId } = useParams<{ sourceId: string }>(); @@ -183,7 +186,7 @@ export default function SourceMangas() { setSize: setPages, mutate: refreshData, abortRequest, - } = useSourceManga(sourceId, contentType, searchTerm, filtersToApply); + } = useSourceManga(sourceId, contentType, searchTerm, filtersToApply, isLargeScreen ? 2 : 1); const { data: filters = [], mutate: mutateFilters } = requestManager.useGetSourceFilters(sourceId); const { data: source } = requestManager.useGetSource(sourceId); const [triggerDataRefresh, setTriggerDataRefresh] = useState(false); @@ -222,15 +225,13 @@ export default function SourceMangas() { updateContentType(currentLocationContentType, false); } - let wasLoadMoreTriggered = false; - const setLastPageNum = useCallback(() => { - if (!hasNextPage || wasLoadMoreTriggered) { + const loadMore = useCallback(() => { + if (!hasNextPage) { return; } - wasLoadMoreTriggered = true; setPages(lastPageNum + 1); - }, [setPages, hasNextPage, lastPageNum]); + }, [setPages, lastPageNum, hasNextPage]); const resetFilters = useCallback(async () => { setDialogFiltersToApply([]); @@ -325,8 +326,7 @@ export default function SourceMangas() { { }, }, components: { + MuiUseMediaQuery: { + defaultProps: { + noSsr: true, + }, + }, MuiCssBaseline: { styleOverrides: ` *::-webkit-scrollbar {