/* * 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, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import Grid, { GridTypeMap } from '@mui/material/Grid'; import Box from '@mui/material/Box'; import { GridItemProps, GridStateSnapshot, VirtuosoGrid } from 'react-virtuoso'; import { useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { EmptyViewAbsoluteCentered } from '@/components/util/EmptyViewAbsoluteCentered.tsx'; import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder'; import { MangaCard } from '@/components/MangaCard'; import { GridLayout } from '@/components/context/LibraryOptionsContext'; import { useLocalStorage, useSessionStorage } from '@/util/useStorage.tsx'; import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts'; import { DEFAULT_FULL_FAB_HEIGHT } from '@/components/util/StyledFab.tsx'; import { AppStorage } from '@/util/AppStorage.ts'; import { MangaCardProps } from '@/components/manga/MangaCard.types.tsx'; import { MangaType } from '@/lib/graphql/generated/graphql.ts'; import { useResizeObserver } from '@/util/useResizeObserver.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; // MUI GridProps and Virtuoso GridItemProps use different types for the "ref" prop which conflict with each other return ({ children, ...itemProps }: GridTypeMap['props'] & Omit, 'ref'>) => ( {children} ); }; type TManga = MangaCardProps['manga']; const createMangaCard = ( manga: TManga, gridLayout?: GridLayout, inLibraryIndicator?: boolean, isSelectModeActive: boolean = false, selectedMangaIds?: MangaType['id'][], handleSelection?: DefaultGridProps['handleSelection'], mode?: MangaCardProps['mode'], ) => ( ); type DefaultGridProps = Pick & { isLoading: boolean; mangas: TManga[]; 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, )} )) )} ), ); export const getGridSnapshotKey = (location: ReturnType) => `MangaGrid-snapshot-location-${location.key}`; 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 snapshotSessionKey = getGridSnapshotKey(location); const [snapshot] = useSessionStorage(snapshotSessionKey, undefined); 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; } AppStorage.session.setItem(snapshotSessionKey, gridState, false); }, 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, Partial> { hasNextPage: boolean; loadMore: () => void; horizontal?: boolean | undefined; noFaces?: boolean | undefined; } export const MangaGrid: React.FC = ({ mangas, isLoading, message, messageExtra, hasNextPage, loadMore, gridLayout, horizontal, noFaces, inLibraryIndicator, isSelectModeActive, selectedMangaIds, handleSelection, mode, retry, }) => { const { t } = useTranslation(); 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, []); // always show vertical scrollbar to prevent https://github.com/Suwayomi/Suwayomi-WebUI/issues/758 useLayoutEffect(() => { // in case "overflow" is currently set to "hidden" that (most likely) means that a MUI modal is open and locks the scrollbar // once this modal is closed MUI restores the previous "overflow" value, thus, reverting the just set "overflow" value let timeout: NodeJS.Timeout; const changeStyle = (timeoutMS: number) => { timeout = setTimeout(() => { if (document.body.style.overflow.includes('hidden')) { changeStyle(250); return; } document.body.style.overflowY = gridLayout === GridLayout.List ? 'auto' : 'scroll'; }, timeoutMS); }; changeStyle(0); return () => { clearTimeout(timeout); }; }, [gridLayout]); useLayoutEffect( () => () => { document.body.style.overflowY = 'auto'; }, [], ); useEffect(() => { let movementTimer: NodeJS.Timeout; const onResize = () => { clearInterval(movementTimer); movementTimer = setTimeout(updateGridWidth, 100); }; window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); useResizeObserver( gridRef, useCallback( (entries, resizeObserver) => { const gridHeight = entries[0].target.clientHeight; const isScrollbarVisible = gridHeight > document.documentElement.clientHeight; if (isLoading) { return; } if (!gridHeight) { return; } if (isScrollbarVisible) { resizeObserver.disconnect(); return; } loadMore(); resizeObserver.disconnect(); }, [gridRef, loadMore, isLoading], ), ); const hasNoItems = !isLoading && mangas.length === 0; if (hasNoItems) { return ( ); } return (
{horizontal ? ( ) : ( )}
); };