Files
suwayomi-material-you-webui/src/modules/chapter/components/ChapterList.tsx

280 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
* 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 Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import { styled } from '@mui/material/styles';
import Typography from '@mui/material/Typography';
import { ComponentProps, useCallback, useMemo, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { useTranslation } from 'react-i18next';
2023-12-18 22:13:43 +01:00
import IconButton from '@mui/material/IconButton';
import DownloadIcon from '@mui/icons-material/Download';
import DoneAllIcon from '@mui/icons-material/DoneAll';
2024-04-20 01:40:41 +02:00
import PopupState, { bindMenu, bindTrigger } from 'material-ui-popup-state';
import Menu from '@mui/material/Menu';
2024-10-05 16:09:34 +02:00
import { requestManager } from '@/lib/requests/requests/RequestManager.ts';
2024-10-05 19:15:25 +02:00
import { ChapterCard } from '@/modules/chapter/components/cards/ChapterCard.tsx';
2024-10-05 19:21:26 +02:00
import { ResumeFab } from '@/modules/manga/components/ResumeFAB.tsx';
2024-10-05 19:15:25 +02:00
import { filterAndSortChapters } from '@/modules/chapter/utils/ChapterList.util.tsx';
2024-10-05 16:09:34 +02:00
import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx';
2024-10-05 19:15:25 +02:00
import { ChaptersToolbarMenu } from '@/modules/chapter/components/ChaptersToolbarMenu.tsx';
2024-10-05 19:24:45 +02:00
import { SelectionFAB } from '@/modules/collection/components/SelectionFAB.tsx';
2024-10-05 16:09:34 +02:00
import { DEFAULT_FULL_FAB_HEIGHT } from '@/modules/core/components/buttons/StyledFab.tsx';
import {
GetChaptersMangaQuery,
GetChaptersMangaQueryVariables,
2024-07-08 18:02:50 +02:00
MangaScreenFieldsFragment,
} from '@/lib/graphql/generated/graphql.ts';
2024-10-05 19:24:45 +02:00
import { useSelectableCollection } from '@/modules/collection/hooks/useSelectableCollection.ts';
import { SelectableCollectionSelectAll } from '@/modules/collection/components/SelectableCollectionSelectAll.tsx';
2024-10-05 19:15:25 +02:00
import { Chapters } from '@/modules/chapter/services/Chapters.ts';
import { ChaptersWithMeta, ChapterWithMetaType } from '@/modules/chapter/services/ChaptersWithMeta.ts';
import { ChapterActionMenuItems } from '@/modules/chapter/components/actions/ChapterActionMenuItems.tsx';
import { ChaptersDownloadActionMenuItems } from '@/modules/chapter/components/actions/ChaptersDownloadActionMenuItems.tsx';
2024-10-05 16:09:34 +02:00
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { LoadingPlaceholder } from '@/modules/core/components/placeholder/LoadingPlaceholder.tsx';
import { GET_CHAPTERS_MANGA } from '@/lib/graphql/queries/ChapterQuery.ts';
2024-10-05 19:21:26 +02:00
import { Mangas } from '@/modules/manga/services/Mangas.ts';
import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx';
2024-10-05 16:09:34 +02:00
import { useResizeObserver } from '@/modules/core/hooks/useResizeObserver.tsx';
2024-10-05 23:22:42 +02:00
import { MediaQuery } from '@/modules/core/utils/MediaQuery.tsx';
2024-10-05 16:09:34 +02:00
import { shouldForwardProp } from '@/modules/core/utils/ShouldForwardProp.ts';
2024-10-05 19:15:25 +02:00
import { useChapterOptions } from '@/modules/chapter/hooks/useChapterOptions.tsx';
2024-09-27 20:34:32 +02:00
type ChapterListHeaderProps = {
2024-09-06 23:50:38 +02:00
scrollbarWidth: number;
2024-09-27 20:34:32 +02:00
};
const ChapterListHeader = styled(Stack, {
shouldForwardProp: shouldForwardProp<ChapterListHeaderProps>(['scrollbarWidth']),
})<ChapterListHeaderProps>(({ theme, scrollbarWidth }) => ({
padding: theme.spacing(1),
2024-09-06 23:50:38 +02:00
paddingRight: `calc(${scrollbarWidth}px + ${theme.spacing(1)})`,
paddingBottom: 0,
[theme.breakpoints.down('md')]: {
paddingRight: theme.spacing(1),
},
}));
2024-09-27 20:34:32 +02:00
type StyledVirtuosoProps = { topOffset: number };
const StyledVirtuoso = styled(Virtuoso, {
shouldForwardProp: shouldForwardProp<StyledVirtuosoProps>(['topOffset']),
})<StyledVirtuosoProps>(({ theme, topOffset }) => ({
listStyle: 'none',
padding: 0,
[theme.breakpoints.up('md')]: {
height: `calc(100vh - ${topOffset}px)`,
margin: 0,
},
}));
export interface IChapterWithMeta extends ChapterWithMetaType<ComponentProps<typeof ChapterCard>['chapter']> {
selected: boolean | null;
}
export const ChapterList = ({
manga,
isRefreshing,
}: {
manga: Pick<MangaScreenFieldsFragment, 'id' | 'firstUnreadChapter' | 'chapters' | 'unreadCount' | 'downloadCount'>;
isRefreshing: boolean;
}) => {
const { t } = useTranslation();
const { appBarHeight } = useNavBarContext();
const isMobileWidth = MediaQuery.useIsBelowWidth('md');
const [chapterListHeaderHeight, setChapterListHeaderHeight] = useState(50);
const [chapterListHeaderRef, setChapterListHeaderRef] = useState<HTMLDivElement | null>(null);
useResizeObserver(
chapterListHeaderRef,
useCallback(() => setChapterListHeaderHeight(chapterListHeaderRef?.offsetHeight ?? 0), [chapterListHeaderRef]),
);
2024-09-06 23:50:38 +02:00
const scrollbarWidth = MediaQuery.useGetScrollbarSize('width');
const { data: downloaderData } = requestManager.useGetDownloadStatus();
const queue = downloaderData?.downloadStatus.queue ?? [];
const [options, dispatch] = useChapterOptions(manga.id);
2024-04-27 22:28:55 +02:00
const {
data: chaptersData,
loading: isLoading,
error,
refetch,
} = requestManager.useGetMangaChapters<GetChaptersMangaQuery, GetChaptersMangaQueryVariables>(
GET_CHAPTERS_MANGA,
manga.id,
{ notifyOnNetworkStatusChange: true },
);
2023-10-15 16:03:08 +02:00
const chapters = useMemo(() => chaptersData?.chapters.nodes ?? [], [chaptersData?.chapters.nodes]);
2024-04-05 02:03:00 +02:00
const chapterIds = useMemo(() => chapters.map((chapter) => chapter.id), [chapters]);
const { areNoItemsSelected, areAllItemsSelected, selectedItemIds, handleSelectAll, handleSelection } =
2024-04-05 02:03:00 +02:00
useSelectableCollection(chapters.length, { itemIds: chapterIds, currentKey: 'default' });
const visibleChapters = useMemo(() => filterAndSortChapters(chapters, options), [chapters, options]);
const nextChapterIndexToRead = manga.firstUnreadChapter?.sourceOrder;
const areAllChaptersRead = Mangas.isFullyRead(manga);
const areAllChaptersDownloaded = Mangas.isFullyDownloaded(manga);
2023-12-18 22:13:43 +01:00
const noChaptersFound = chapters.length === 0;
const noChaptersMatchingFilter = !noChaptersFound && visibleChapters.length === 0;
const chaptersWithMeta: IChapterWithMeta[] = useMemo(
() =>
visibleChapters.map((chapter) => {
const downloadChapter = queue?.find((cd) => cd.chapter.id === chapter.id);
const selected = !areNoItemsSelected ? selectedItemIds.includes(chapter.id) : null;
return {
chapter,
downloadChapter,
selected,
};
}),
[queue, selectedItemIds, visibleChapters],
);
const chapterListFAB = useMemo(() => {
const selectedChapters = chaptersWithMeta.filter((chapter) => chapter.selected);
if (selectedChapters.length) {
return (
2024-07-01 19:51:27 +02:00
<SelectionFAB selectedItemsCount={selectedChapters.length} title="chapter.title_one">
{(handleClose) => (
<ChapterActionMenuItems selectedChapters={selectedChapters} onClose={handleClose} />
)}
</SelectionFAB>
);
}
if (nextChapterIndexToRead !== undefined) {
return <ResumeFab chapterIndex={nextChapterIndexToRead} mangaId={manga.id} />;
}
return null;
}, [chaptersWithMeta]);
if (isLoading || (noChaptersFound && isRefreshing)) {
return (
2024-04-27 22:28:55 +02:00
<Stack sx={{ justifyContent: 'center', alignItems: 'center', position: 'relative', flexGrow: 1 }}>
<LoadingPlaceholder />
</Stack>
);
}
if (error) {
return (
<Stack sx={{ justifyContent: 'center', position: 'relative', flexGrow: 1 }}>
2024-04-29 15:29:33 +02:00
<EmptyViewAbsoluteCentered
2024-04-27 22:28:55 +02:00
message={t('global.error.label.failed_to_load_data')}
messageExtra={error.message}
retry={() => refetch().catch(defaultPromiseErrorHandler('ChapterList::refetch'))}
/>
</Stack>
);
}
return (
<>
2024-08-30 04:34:28 +02:00
<Stack direction="column" sx={{ position: 'relative', flexBasis: '60%' }}>
<ChapterListHeader
ref={setChapterListHeaderRef}
direction="row"
alignItems="center"
justifyContent="space-between"
2024-09-06 23:50:38 +02:00
scrollbarWidth={scrollbarWidth}
>
2024-08-30 15:52:02 +02:00
<Typography variant="h5" component="h3">
2024-07-01 19:51:27 +02:00
{`${visibleChapters.length} ${t('chapter.title_one', {
count: visibleChapters.length,
})}`}
</Typography>
2024-08-30 15:52:02 +02:00
<Stack direction="row">
<Tooltip title={t('chapter.action.mark_as_read.add.label.action.current')}>
<IconButton
disabled={areAllChaptersRead}
onClick={() =>
Chapters.markAsRead(
ChaptersWithMeta.getChapters(ChaptersWithMeta.getNonRead(chaptersWithMeta)),
true,
manga.id,
)
}
>
<DoneAllIcon />
</IconButton>
</Tooltip>
2024-04-20 01:40:41 +02:00
<PopupState variant="popover" popupId="chapterlist-download-button">
{(popupState) => (
<>
<Tooltip title={t('chapter.action.download.add.label.action')}>
<IconButton disabled={areAllChaptersDownloaded} {...bindTrigger(popupState)}>
<DownloadIcon />
</IconButton>
</Tooltip>
{popupState.isOpen && (
<Menu {...bindMenu(popupState)}>
<ChaptersDownloadActionMenuItems
mangaIds={[manga.id]}
closeMenu={popupState.close}
/>
</Menu>
)}
</>
)}
</PopupState>
<ChaptersToolbarMenu options={options} optionsDispatch={dispatch} />
<SelectableCollectionSelectAll
areAllItemsSelected={areAllItemsSelected}
areNoItemsSelected={areNoItemsSelected}
onChange={(checked) =>
handleSelectAll(checked, checked ? chapters.map((chapter) => chapter.id) : [])
}
/>
</Stack>
</ChapterListHeader>
2024-04-29 15:29:33 +02:00
{noChaptersFound && <EmptyViewAbsoluteCentered message={t('chapter.error.label.no_chapter_found')} />}
{noChaptersMatchingFilter && (
<EmptyViewAbsoluteCentered message={t('chapter.error.label.no_matches')} />
)}
<StyledVirtuoso
topOffset={appBarHeight + chapterListHeaderHeight}
style={{
// override Virtuoso default values and set them with class
height: 'undefined',
}}
components={{ Footer: () => <Box sx={{ paddingBottom: DEFAULT_FULL_FAB_HEIGHT }} /> }}
totalCount={visibleChapters.length}
itemContent={(index: number) => (
<ChapterCard
{...chaptersWithMeta[index]}
allChapters={chapters}
showChapterNumber={options.showChapterNumber}
2024-04-05 02:03:00 +02:00
onSelect={(selected, selectRange) =>
handleSelection(chaptersWithMeta[index].chapter.id, selected, { selectRange })
}
/>
)}
useWindowScroll={isMobileWidth}
overscan={window.innerHeight * 0.5}
/>
</Stack>
{chapterListFAB}
</>
);
};