/* * 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 React, { ForwardedRef, forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import Grid, { GridTypeMap } from '@mui/material/Grid'; import { Box, Typography } from '@mui/material'; import { GridItemProps, GridStateSnapshot, VirtuosoGrid } from 'react-virtuoso'; import { useLocation, useNavigate } from 'react-router-dom'; import { EmptyView } from '@/components/util/EmptyView'; import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder'; import { MangaCard, MangaCardProps } from '@/components/MangaCard'; import { GridLayout } from '@/components/context/LibraryOptionsContext'; import { useLocalStorage } from '@/util/useLocalStorage'; import { TManga, TPartialManga } from '@/typings.ts'; import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts'; import { DEFAULT_FULL_FAB_HEIGHT } from '@/components/util/StyledFab.tsx'; const GridContainer = React.forwardRef(({ children, ...props }, ref) => ( {children} )); const GridItemContainerWithDimension = ( dimensions: number, itemWidth: number, gridLayout?: GridLayout, maxColumns: number = 12, ) => { const itemsPerRow = Math.ceil(dimensions / itemWidth); const columnsPerItem = gridLayout === GridLayout.List ? maxColumns : maxColumns / itemsPerRow; return ({ children, ...itemProps }: GridTypeMap['props'] & Partial) => ( {children} ); }; const createMangaCard = ( manga: TPartialManga, gridLayout?: GridLayout, inLibraryIndicator?: boolean, isSelectModeActive: boolean = false, selectedMangaIds?: TManga['id'][], handleSelection?: DefaultGridProps['handleSelection'], mode?: MangaCardProps['mode'], ) => ( ); type DefaultGridProps = Pick & { isLoading: boolean; mangas: TPartialManga[]; inLibraryIndicator?: boolean; GridItemContainer: (props: GridTypeMap['props'] & Partial) => JSX.Element; gridLayout?: GridLayout; isSelectModeActive?: boolean; selectedMangaIds?: Required[]; handleSelection?: SelectableCollectionReturnType['handleSelection']; }; const HorizontalGrid = forwardRef( ( { isLoading, mangas, inLibraryIndicator, GridItemContainer, gridLayout, isSelectModeActive, selectedMangaIds, handleSelection, mode, }: DefaultGridProps, ref: ForwardedRef, ) => ( {isLoading ? ( ) : ( mangas.map((manga) => ( {createMangaCard( manga, gridLayout, inLibraryIndicator, isSelectModeActive, selectedMangaIds, handleSelection, mode, )} )) )} ), ); const VerticalGrid = forwardRef( ( { isLoading, mangas, inLibraryIndicator, GridItemContainer, gridLayout, hasNextPage, loadMore, isSelectModeActive, selectedMangaIds, handleSelection, mode, }: DefaultGridProps & { hasNextPage: boolean; loadMore: () => void; }, ref: ForwardedRef, ) => { const location = useLocation<{ snapshot?: GridStateSnapshot }>(); const navigate = useNavigate(); const { snapshot } = location.state ?? {}; const persistGridStateTimeout = useRef(); const persistGridState = (gridState: GridStateSnapshot) => { const currentUrl = window.location.href; clearTimeout(persistGridStateTimeout.current); persistGridStateTimeout.current = setTimeout(() => { const didLocationChange = currentUrl !== window.location.href; if (didLocationChange) { return; } navigate( { pathname: '', search: location.search }, { replace: true, state: { ...location.state, snapshot: gridState } }, ); }, 250); }; useEffect(() => clearTimeout(persistGridStateTimeout.current), [location.key, persistGridStateTimeout.current]); return ( <> loadMore()} itemContent={(index) => createMangaCard( mangas[index], gridLayout, inLibraryIndicator, isSelectModeActive, selectedMangaIds, handleSelection, mode, ) } /> {/* render div to prevent UI jumping around when showing/hiding loading placeholder */ /* eslint-disable-next-line no-nested-ternary */} {isSelectModeActive && gridLayout === GridLayout.List ? ( ) : // eslint-disable-next-line no-nested-ternary isLoading ? ( ) : hasNextPage ? (
) : null} ); }, ); export interface IMangaGridProps extends Omit { message?: string; messageExtra?: JSX.Element; hasNextPage: boolean; loadMore: () => void; horizontal?: boolean | undefined; noFaces?: boolean | undefined; } export const MangaGrid: React.FC = (props) => { const { mangas, isLoading, message, messageExtra, hasNextPage, loadMore, gridLayout, horizontal, noFaces, inLibraryIndicator, isSelectModeActive, selectedMangaIds, handleSelection, mode, } = props; const gridRef = useRef(null); const [dimensions, setDimensions] = useState(document.documentElement.offsetWidth); const [gridItemWidth] = useLocalStorage('ItemWidth', 300); const gridWrapperRef = useRef(null); const GridItemContainer = useMemo( () => GridItemContainerWithDimension(dimensions, gridItemWidth, gridLayout), [dimensions, gridItemWidth, gridLayout], ); const updateGridWidth = () => { const getDimensions = () => { const gridWidth = gridWrapperRef.current?.offsetWidth; if (!gridWidth) { return document.documentElement.offsetWidth; } return gridWidth; }; setDimensions(getDimensions()); }; useLayoutEffect(updateGridWidth, []); useEffect(() => { let movementTimer: NodeJS.Timeout; const onResize = () => { clearInterval(movementTimer); movementTimer = setTimeout(updateGridWidth, 100); }; window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); useEffect(() => { if (!gridRef.current) { return () => {}; } if (gridRef.current.offsetHeight > document.documentElement.clientHeight) { return () => {}; } const resizeObserver = new ResizeObserver(() => { const gridHeight = gridRef.current!.offsetHeight; const isScrollbarVisible = gridHeight > document.documentElement.clientHeight; if (!gridHeight) { return; } if (isScrollbarVisible) { resizeObserver.disconnect(); return; } loadMore(); resizeObserver.disconnect(); }); resizeObserver.observe(gridRef.current); return () => resizeObserver.disconnect(); }, [loadMore]); const hasNoItems = !isLoading && mangas.length === 0; if (hasNoItems) { if (noFaces) { return ( {message} {messageExtra} ); } return ; } return (
{horizontal ? ( ) : ( )}
); }; MangaGrid.defaultProps = { message: '', messageExtra: undefined, };