Files
suwayomi-material-you-webui/src/components/MangaGrid.tsx
T

369 lines
12 KiB
TypeScript
Raw Normal View History

/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
2021-01-26 23:32:12 +03:30
* 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/.
*/
2021-01-26 23:32:12 +03:30
import React, { ForwardedRef, forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
2023-06-17 16:50:17 +02:00
import Grid, { GridTypeMap } from '@mui/material/Grid';
import { Box, Typography } from '@mui/material';
import { GridItemProps, GridStateSnapshot, VirtuosoGrid } from 'react-virtuoso';
2023-12-25 21:37:45 +01:00
import { useLocation, useNavigate } from 'react-router-dom';
2023-10-28 00:32:02 +02:00
import { EmptyView } from '@/components/util/EmptyView';
import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder';
2024-01-26 20:50:51 +01:00
import { MangaCard, MangaCardProps } from '@/components/MangaCard';
2023-06-05 01:42:39 +02:00
import { GridLayout } from '@/components/context/LibraryOptionsContext';
2023-10-28 00:32:02 +02:00
import { useLocalStorage } from '@/util/useLocalStorage';
2023-12-25 21:37:45 +01:00
import { TManga, TPartialManga } from '@/typings.ts';
import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts';
import { DEFAULT_FULL_FAB_HEIGHT } from '@/components/util/StyledFab.tsx';
2023-06-17 16:50:17 +02:00
const GridContainer = React.forwardRef<HTMLDivElement, GridTypeMap['props']>(({ children, ...props }, ref) => (
<Grid {...props} ref={ref} container sx={{ paddingLeft: '5px', paddingRight: '13px' }}>
{children}
</Grid>
));
2023-06-17 20:53:08 +02:00
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;
2023-06-17 16:50:17 +02:00
2023-06-17 20:53:08 +02:00
return ({ children, ...itemProps }: GridTypeMap['props'] & Partial<GridItemProps>) => (
<Grid {...itemProps} item xs={columnsPerItem} sx={{ width: '100%', paddingTop: '8px', paddingLeft: '8px' }}>
2023-06-17 20:53:08 +02:00
{children}
</Grid>
);
};
2023-06-17 16:50:17 +02:00
2023-12-25 21:37:45 +01:00
const createMangaCard = (
manga: TPartialManga,
gridLayout?: GridLayout,
inLibraryIndicator?: boolean,
isSelectModeActive: boolean = false,
selectedMangaIds?: TManga['id'][],
handleSelection?: DefaultGridProps['handleSelection'],
2024-01-26 20:50:51 +01:00
mode?: MangaCardProps['mode'],
2023-12-25 21:37:45 +01:00
) => (
<MangaCard
key={manga.id}
manga={manga}
gridLayout={gridLayout}
inLibraryIndicator={inLibraryIndicator}
selected={isSelectModeActive ? selectedMangaIds?.includes(manga.id) : null}
handleSelection={handleSelection}
2024-01-26 20:50:51 +01:00
mode={mode}
2023-12-25 21:37:45 +01:00
/>
2023-06-17 16:50:17 +02:00
);
2024-01-26 20:50:51 +01:00
type DefaultGridProps = Pick<MangaCardProps, 'mode'> & {
2023-06-17 16:50:17 +02:00
isLoading: boolean;
2023-10-15 16:03:08 +02:00
mangas: TPartialManga[];
2023-06-17 16:50:17 +02:00
inLibraryIndicator?: boolean;
GridItemContainer: (props: GridTypeMap['props'] & Partial<GridItemProps>) => JSX.Element;
gridLayout?: GridLayout;
2023-12-25 21:37:45 +01:00
isSelectModeActive?: boolean;
selectedMangaIds?: Required<TManga['id']>[];
handleSelection?: SelectableCollectionReturnType<TManga['id']>['handleSelection'];
2023-06-17 16:50:17 +02:00
};
const HorizontalGrid = forwardRef(
(
{
isLoading,
mangas,
inLibraryIndicator,
GridItemContainer,
gridLayout,
isSelectModeActive,
selectedMangaIds,
handleSelection,
mode,
}: DefaultGridProps,
ref: ForwardedRef<HTMLDivElement | null>,
) => (
<Grid
ref={ref}
container
spacing={1}
style={{
margin: 0,
width: '100%',
padding: '5px',
overflowX: 'auto',
display: '-webkit-inline-box',
flexWrap: 'nowrap',
}}
>
{isLoading ? (
<LoadingPlaceholder />
) : (
mangas.map((manga) => (
<GridItemContainer key={manga.id}>
{createMangaCard(
manga,
gridLayout,
inLibraryIndicator,
isSelectModeActive,
selectedMangaIds,
handleSelection,
mode,
)}
</GridItemContainer>
))
)}
</Grid>
),
2023-06-17 16:50:17 +02:00
);
const VerticalGrid = forwardRef(
(
{
isLoading,
mangas,
inLibraryIndicator,
GridItemContainer,
gridLayout,
hasNextPage,
loadMore,
isSelectModeActive,
selectedMangaIds,
handleSelection,
mode,
}: DefaultGridProps & {
hasNextPage: boolean;
loadMore: () => void;
},
ref: ForwardedRef<HTMLDivElement | null>,
) => {
const location = useLocation<{ snapshot?: GridStateSnapshot }>();
const navigate = useNavigate();
const { snapshot } = location.state ?? {};
2023-06-17 16:50:17 +02:00
const persistGridStateTimeout = useRef<NodeJS.Timeout | undefined>();
const persistGridState = (gridState: GridStateSnapshot) => {
const currentUrl = window.location.href;
clearTimeout(persistGridStateTimeout.current);
persistGridStateTimeout.current = setTimeout(() => {
const didLocationChange = currentUrl !== window.location.href;
if (didLocationChange) {
return;
2023-12-25 21:37:45 +01:00
}
navigate(
{ pathname: '', search: location.search },
{ replace: true, state: { ...location.state, snapshot: gridState } },
);
}, 250);
};
useEffect(() => clearTimeout(persistGridStateTimeout.current), [location.key, persistGridStateTimeout.current]);
return (
<>
<Box ref={ref}>
<VirtuosoGrid
useWindowScroll
overscan={window.innerHeight * 0.25}
totalCount={mangas.length}
components={{
List: GridContainer,
Item: GridItemContainer,
}}
restoreStateFrom={snapshot}
stateChanged={persistGridState}
endReached={() => loadMore()}
itemContent={(index) =>
createMangaCard(
mangas[index],
gridLayout,
inLibraryIndicator,
isSelectModeActive,
selectedMangaIds,
handleSelection,
mode,
)
}
/>
</Box>
{/* render div to prevent UI jumping around when showing/hiding loading placeholder */
/* eslint-disable-next-line no-nested-ternary */}
{isSelectModeActive && gridLayout === GridLayout.List ? (
<Box sx={{ paddingBottom: DEFAULT_FULL_FAB_HEIGHT }} />
) : // eslint-disable-next-line no-nested-ternary
isLoading ? (
<LoadingPlaceholder />
) : hasNextPage ? (
<div style={{ height: '75px' }} />
) : null}
</>
);
},
);
2021-01-20 15:26:52 +03:30
2023-12-25 21:37:45 +01:00
export interface IMangaGridProps extends Omit<DefaultGridProps, 'GridItemContainer'> {
2023-02-06 10:06:33 +01:00
message?: string;
messageExtra?: JSX.Element;
hasNextPage: boolean;
2023-06-17 16:50:17 +02:00
loadMore: () => void;
horizontal?: boolean | undefined;
2023-02-06 10:06:33 +01:00
noFaces?: boolean | undefined;
2021-01-20 15:26:52 +03:30
}
2023-10-28 00:32:02 +02:00
export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
2021-01-22 21:11:00 +03:30
const {
2023-02-06 10:06:33 +01:00
mangas,
isLoading,
message,
messageExtra,
hasNextPage,
2023-06-17 16:50:17 +02:00
loadMore,
2023-02-06 10:06:33 +01:00
gridLayout,
horizontal,
2023-02-06 10:06:33 +01:00
noFaces,
2022-11-24 21:04:30 +01:00
inLibraryIndicator,
2023-12-25 21:37:45 +01:00
isSelectModeActive,
selectedMangaIds,
handleSelection,
2024-01-26 20:50:51 +01:00
mode,
2021-01-22 21:11:00 +03:30
} = props;
2022-04-02 01:34:10 +01:00
const gridRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState(document.documentElement.offsetWidth);
2023-06-17 16:50:17 +02:00
const [gridItemWidth] = useLocalStorage<number>('ItemWidth', 300);
const gridWrapperRef = useRef<HTMLDivElement>(null);
2023-06-17 16:50:17 +02:00
const GridItemContainer = useMemo(
() => GridItemContainerWithDimension(dimensions, gridItemWidth, gridLayout),
2023-06-17 16:50:17 +02:00
[dimensions, gridItemWidth, gridLayout],
);
2022-04-02 01:34:10 +01:00
2023-06-17 16:50:17 +02:00
const updateGridWidth = () => {
const getDimensions = () => {
const gridWidth = gridWrapperRef.current?.offsetWidth;
if (!gridWidth) {
return document.documentElement.offsetWidth;
}
return gridWidth;
};
setDimensions(getDimensions());
2022-04-02 01:34:10 +01:00
};
useLayoutEffect(updateGridWidth, []);
2022-04-02 01:34:10 +01:00
2023-06-17 16:50:17 +02:00
useEffect(() => {
let movementTimer: NodeJS.Timeout;
2022-04-02 01:34:10 +01:00
2023-06-17 16:50:17 +02:00
const onResize = () => {
clearInterval(movementTimer);
movementTimer = setTimeout(updateGridWidth, 100);
};
2022-04-02 01:34:10 +01:00
2023-06-17 16:50:17 +02:00
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]);
2023-06-17 16:50:17 +02:00
const hasNoItems = !isLoading && mangas.length === 0;
if (hasNoItems) {
if (noFaces) {
return (
2022-03-12 04:46:55 +00:00
<Box
sx={{
margin: 'auto',
}}
>
2023-02-06 10:06:33 +01:00
<Typography variant="h5">{message}</Typography>
2022-03-12 04:46:55 +00:00
{messageExtra}
</Box>
2021-10-24 22:56:10 +03:30
);
}
2023-06-17 16:50:17 +02:00
return <EmptyView message={message!} messageExtra={messageExtra} />;
2021-01-20 15:26:52 +03:30
}
2021-01-22 21:11:00 +03:30
return (
2023-06-17 16:50:17 +02:00
<div
ref={gridWrapperRef}
2023-06-17 16:50:17 +02:00
style={{
overflow: 'hidden',
paddingBottom: '13px',
}}
>
{horizontal ? (
<HorizontalGrid
ref={gridRef}
2023-06-17 16:50:17 +02:00
isLoading={isLoading}
mangas={mangas}
inLibraryIndicator={inLibraryIndicator}
GridItemContainer={GridItemContainer}
gridLayout={gridLayout}
2023-12-25 21:37:45 +01:00
isSelectModeActive={isSelectModeActive}
selectedMangaIds={selectedMangaIds}
handleSelection={handleSelection}
2024-01-26 20:50:51 +01:00
mode={mode}
2023-06-17 16:50:17 +02:00
/>
) : (
<VerticalGrid
ref={gridRef}
2023-06-17 16:50:17 +02:00
isLoading={isLoading}
mangas={mangas}
inLibraryIndicator={inLibraryIndicator}
2023-06-17 16:50:17 +02:00
GridItemContainer={GridItemContainer}
hasNextPage={hasNextPage}
loadMore={loadMore}
gridLayout={gridLayout}
2023-12-25 21:37:45 +01:00
isSelectModeActive={isSelectModeActive}
selectedMangaIds={selectedMangaIds}
handleSelection={handleSelection}
2024-01-26 20:50:51 +01:00
mode={mode}
2023-06-17 16:50:17 +02:00
/>
)}
2022-04-02 01:34:10 +01:00
</div>
2021-01-22 21:11:00 +03:30
);
2022-11-24 21:04:30 +01:00
};
2021-01-22 21:11:00 +03:30
MangaGrid.defaultProps = {
2021-10-24 22:56:10 +03:30
message: '',
2021-09-19 17:30:34 +04:30
messageExtra: undefined,
2021-01-22 21:11:00 +03:30
};