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

358 lines
11 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
2025-09-24 00:51:13 +02:00
import React, { ForwardedRef, Ref, useCallback, useLayoutEffect, useMemo, useRef, useState, type JSX } from 'react';
2025-04-09 22:39:28 +02:00
import Grid, { GridTypeMap } from '@mui/material/Grid';
2024-09-14 03:01:07 +02:00
import Box, { BoxProps } from '@mui/material/Box';
import { GridItemProps } from 'react-virtuoso';
2026-01-10 19:45:02 +01:00
import { useLingui } from '@lingui/react/macro';
import { EmptyViewAbsoluteCentered } from '@/base/components/feedback/EmptyViewAbsoluteCentered.tsx';
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
2025-08-15 22:02:58 +02:00
import { MangaCard } from '@/features/manga/components/cards/MangaCard.tsx';
import { SelectableCollectionReturnType } from '@/base/collection/hooks/useSelectableCollection.ts';
import { DEFAULT_FULL_FAB_HEIGHT } from '@/base/components/buttons/StyledFab.tsx';
2025-08-15 22:02:58 +02:00
import { MangaCardProps } from '@/features/manga/Manga.types.ts';
2024-07-08 18:02:50 +02:00
import { MangaType } from '@/lib/graphql/generated/graphql.ts';
import { useResizeObserver } from '@/base/hooks/useResizeObserver.tsx';
import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx';
import { GridLayout } from '@/base/Base.types.ts';
2025-08-15 22:02:58 +02:00
import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
import { VirtuosoGridPersisted } from '@/lib/virtuoso/Component/VirtuosoGridPersisted.tsx';
2023-06-17 16:50:17 +02:00
2025-09-24 00:51:13 +02:00
const GridContainer = ({ children, ref, ...props }: GridTypeMap['props'] & { ref?: Ref<HTMLDivElement> }) => (
2024-09-14 03:01:07 +02:00
<Grid {...props} ref={ref} container spacing={1}>
2023-06-17 16:50:17 +02:00
{children}
</Grid>
2025-09-24 00:51:13 +02:00
);
2023-06-17 16:50:17 +02:00
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
2024-03-31 23:34:40 +02:00
// MUI GridProps and Virtuoso GridItemProps use different types for the "ref" prop which conflict with each other
return ({ children, ...itemProps }: GridTypeMap['props'] & Omit<Partial<GridItemProps>, 'ref'>) => (
2024-09-14 03:01:07 +02:00
<Grid {...itemProps} size={columnsPerItem}>
2023-06-17 20:53:08 +02:00
{children}
</Grid>
);
};
2023-06-17 16:50:17 +02:00
2024-07-08 18:02:50 +02:00
type TManga = MangaCardProps['manga'];
2023-12-25 21:37:45 +01:00
const createMangaCard = (
2024-07-08 18:02:50 +02:00
manga: TManga,
2023-12-25 21:37:45 +01:00
gridLayout?: GridLayout,
inLibraryIndicator?: boolean,
isSelectModeActive: boolean = false,
2024-07-08 18:02:50 +02:00
selectedMangaIds?: MangaType['id'][],
2023-12-25 21:37:45 +01:00
handleSelection?: DefaultGridProps['handleSelection'],
2024-01-26 20:50:51 +01:00
mode?: MangaCardProps['mode'],
2023-12-25 21:37:45 +01:00
) => (
<MangaCard
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;
2024-07-08 18:02:50 +02:00
mangas: TManga[];
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;
2024-07-08 18:02:50 +02:00
selectedMangaIds?: Required<MangaType['id']>[];
handleSelection?: SelectableCollectionReturnType<MangaType['id']>['handleSelection'];
2025-09-24 00:51:13 +02:00
ref?: ForwardedRef<HTMLDivElement | null>;
2023-06-17 16:50:17 +02:00
};
2025-09-24 00:51:13 +02:00
const HorizontalGrid = ({
isLoading,
mangas,
inLibraryIndicator,
GridItemContainer,
gridLayout,
isSelectModeActive,
selectedMangaIds,
handleSelection,
mode,
ref,
}: DefaultGridProps) => (
<Grid
ref={ref}
container
spacing={1}
sx={{
width: '100%',
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
);
export const MANGA_GRID_SNAPSHOT_KEY = 'MangaGrid-snapshot-location';
2025-09-24 00:51:13 +02:00
const VerticalGrid = ({
isLoading,
mangas,
inLibraryIndicator,
GridItemContainer,
gridLayout,
hasNextPage,
loadMore,
isSelectModeActive,
selectedMangaIds,
handleSelection,
mode,
ref,
}: DefaultGridProps & {
hasNextPage: boolean;
loadMore: () => void;
}) => (
<>
<Box ref={ref}>
<VirtuosoGridPersisted
persistKey={MANGA_GRID_SNAPSHOT_KEY}
useWindowScroll
increaseViewportBy={window.innerHeight * 0.5}
totalCount={mangas.length}
components={{
List: GridContainer,
Item: GridItemContainer,
}}
endReached={() => loadMore()}
computeItemKey={(index) => mangas[index].id}
itemContent={(index) =>
createMangaCard(
mangas[index],
gridLayout,
inLibraryIndicator,
isSelectModeActive,
selectedMangaIds,
handleSelection,
mode,
)
}
/>
</Box>
2026-03-10 10:59:38 +01:00
{/* render div to prevent UI jumping around when showing/hiding loading placeholder */}
2026-03-10 22:28:19 +01:00
{/* oxlint-disable no-nested-ternary */}
2025-09-24 00:51:13 +02:00
{isSelectModeActive && gridLayout === GridLayout.List ? (
<Box sx={{ paddingBottom: DEFAULT_FULL_FAB_HEIGHT }} />
2026-03-10 10:59:38 +01:00
) : isLoading ? (
2025-09-24 00:51:13 +02:00
<LoadingPlaceholder />
) : hasNextPage ? (
<div style={{ height: '75px' }} />
) : null}
2026-03-10 22:28:19 +01:00
{/* oxlint-enable no-nested-ternary */}
2025-09-24 00:51:13 +02:00
</>
);
2021-01-20 15:26:52 +03:30
2024-04-27 22:29:23 +02:00
export interface IMangaGridProps
2026-03-08 23:25:02 +01:00
extends
Omit<DefaultGridProps, 'GridItemContainer'>,
2024-04-29 15:29:33 +02:00
Partial<React.ComponentProps<typeof EmptyViewAbsoluteCentered>> {
2023-02-06 10:06:33 +01:00
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;
2024-09-14 03:01:07 +02:00
gridWrapperProps?: Omit<BoxProps, 'ref'>;
2021-01-20 15:26:52 +03:30
}
2024-04-29 15:00:37 +02:00
export const MangaGrid: React.FC<IMangaGridProps> = ({
mangas,
isLoading,
message,
messageExtra,
hasNextPage,
loadMore,
gridLayout,
horizontal,
noFaces,
inLibraryIndicator,
isSelectModeActive,
selectedMangaIds,
handleSelection,
mode,
retry,
2024-09-14 03:01:07 +02:00
gridWrapperProps,
2024-04-29 15:00:37 +02:00
}) => {
2026-01-10 19:45:02 +01:00
const { t } = useLingui();
2022-04-02 01:34:10 +01:00
const { navBarWidth } = useNavBarContext();
2025-05-17 21:04:44 +02:00
const {
settings: { mangaGridItemWidth },
} = useMetadataServerSettings();
const gridRef = useRef<HTMLDivElement>(null);
const gridWrapperRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState(
gridWrapperRef.current?.offsetWidth ?? Math.max(0, document.documentElement.offsetWidth - navBarWidth),
);
2023-06-17 16:50:17 +02:00
const GridItemContainer = useMemo(
2025-05-17 21:04:44 +02:00
() => GridItemContainerWithDimension(dimensions, mangaGridItemWidth, gridLayout),
[dimensions, mangaGridItemWidth, gridLayout],
2023-06-17 16:50:17 +02:00
);
2022-04-02 01:34:10 +01:00
// always show vertical scrollbar to prevent https://github.com/Suwayomi/Suwayomi-WebUI/issues/758
useLayoutEffect(() => {
2025-01-13 03:26:07 +01:00
if (horizontal) {
return () => {};
}
// 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(
() => () => {
2025-01-13 03:26:07 +01:00
if (horizontal) {
return;
}
document.body.style.overflowY = 'auto';
},
[],
);
2024-07-23 00:56:16 +02:00
useResizeObserver(
gridWrapperRef,
useCallback(() => {
const getDimensions = () => {
const gridWidth = gridWrapperRef.current?.offsetWidth;
2022-04-02 01:34:10 +01:00
2024-07-23 00:56:16 +02:00
if (!gridWidth) {
return document.documentElement.offsetWidth - navBarWidth;
2024-07-23 00:56:16 +02:00
}
2022-04-02 01:34:10 +01:00
2024-07-23 00:56:16 +02:00
return gridWidth;
};
2023-06-17 16:50:17 +02:00
2024-07-23 00:56:16 +02:00
setDimensions(getDimensions());
}, [navBarWidth]),
2024-07-23 00:56:16 +02:00
);
2023-06-17 16:50:17 +02:00
2024-07-12 23:12:11 +02:00
useResizeObserver(
gridRef,
useCallback(
(entries, resizeObserver) => {
const gridHeight = entries[0].target.clientHeight;
const isScrollbarVisible = gridHeight > document.documentElement.clientHeight;
2024-07-12 23:12:11 +02:00
if (isLoading) {
return;
}
2024-07-12 23:12:11 +02:00
if (!gridHeight) {
return;
}
2024-07-12 23:12:11 +02:00
if (isScrollbarVisible) {
resizeObserver.disconnect();
return;
}
2024-07-12 23:12:11 +02:00
loadMore();
resizeObserver.disconnect();
2024-07-12 23:12:11 +02:00
},
[gridRef, loadMore, isLoading],
),
);
2023-06-17 16:50:17 +02:00
const hasNoItems = !isLoading && mangas.length === 0;
if (hasNoItems) {
2024-04-29 15:29:33 +02:00
return (
2024-04-29 15:00:37 +02:00
<EmptyViewAbsoluteCentered
noFaces={noFaces}
2026-01-10 19:45:02 +01:00
message={message ?? t`No manga found`}
2024-04-29 15:00:37 +02:00
messageExtra={messageExtra}
retry={retry}
/>
2024-04-29 15:29:33 +02:00
);
2021-01-20 15:26:52 +03:30
}
2021-01-22 21:11:00 +03:30
return (
2024-09-14 03:01:07 +02:00
<Box {...gridWrapperProps} ref={gridWrapperRef} sx={{ ...gridWrapperProps?.sx, overflow: 'hidden' }}>
2023-06-17 16:50:17 +02:00
{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
/>
)}
2024-09-14 03:01:07 +02:00
</Box>
2021-01-22 21:11:00 +03:30
);
2022-11-24 21:04:30 +01:00
};