2022-11-09 18:09:15 +01:00
|
|
|
/*
|
|
|
|
|
* 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
|
2023-05-18 13:17:41 +02:00
|
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
|
|
*/
|
2022-11-09 18:09:15 +01:00
|
|
|
|
2023-12-25 21:15:31 +01:00
|
|
|
import { Box, CircularProgress, Stack, styled, Tooltip } from '@mui/material';
|
2022-11-09 18:09:15 +01:00
|
|
|
import Typography from '@mui/material/Typography';
|
2023-12-25 21:20:00 +01:00
|
|
|
import React, { ComponentProps, useMemo } from 'react';
|
2022-11-09 18:09:15 +01:00
|
|
|
import { Virtuoso } from 'react-virtuoso';
|
2023-03-23 13:59:32 +01:00
|
|
|
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';
|
2023-12-18 22:25:25 +01:00
|
|
|
import DoneAllIcon from '@mui/icons-material/DoneAll';
|
2023-10-15 16:03:08 +02:00
|
|
|
import { TChapter, TManga, TranslationKey } from '@/typings';
|
2023-10-28 00:32:02 +02:00
|
|
|
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
|
|
|
|
import { ChapterCard } from '@/components/manga/ChapterCard';
|
|
|
|
|
import { ResumeFab } from '@/components/manga/ResumeFAB';
|
2023-06-05 01:42:39 +02:00
|
|
|
import { filterAndSortChapters, useChapterOptions } from '@/components/manga/util';
|
2023-10-28 00:32:02 +02:00
|
|
|
import { EmptyView } from '@/components/util/EmptyView';
|
|
|
|
|
import { makeToast } from '@/components/util/Toast';
|
|
|
|
|
import { ChaptersToolbarMenu } from '@/components/manga/ChaptersToolbarMenu';
|
|
|
|
|
import { SelectionFAB } from '@/components/manga/SelectionFAB';
|
2023-06-05 01:42:39 +02:00
|
|
|
import { DEFAULT_FULL_FAB_HEIGHT } from '@/components/util/StyledFab';
|
2023-10-15 16:03:08 +02:00
|
|
|
import { DownloadType, UpdateChapterPatchInput } from '@/lib/graphql/generated/graphql.ts';
|
2023-11-19 21:17:35 +01:00
|
|
|
import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
|
2023-12-25 21:20:00 +01:00
|
|
|
import { useSelectableCollection } from '@/components/collection/useSelectableCollection.ts';
|
|
|
|
|
import { SelectableCollectionSelectAll } from '@/components/collection/SelectableCollectionSelectAll.tsx';
|
|
|
|
|
import { ChapterSelectionFABActionItems } from '@/components/manga/ChapterSelectionFABActionItems.tsx';
|
2022-11-09 18:09:15 +01:00
|
|
|
|
2023-12-18 21:40:56 +01:00
|
|
|
const ChapterListHeader = styled(Stack)(({ theme }) => ({
|
|
|
|
|
margin: 8,
|
|
|
|
|
marginBottom: 0,
|
|
|
|
|
marginRight: '10px',
|
|
|
|
|
minHeight: 40,
|
|
|
|
|
[theme.breakpoints.down('md')]: {
|
|
|
|
|
marginRight: 0,
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
2022-11-09 18:09:15 +01:00
|
|
|
const StyledVirtuoso = styled(Virtuoso)(({ theme }) => ({
|
|
|
|
|
listStyle: 'none',
|
|
|
|
|
padding: 0,
|
|
|
|
|
minHeight: '200px',
|
|
|
|
|
[theme.breakpoints.up('md')]: {
|
|
|
|
|
width: '50vw',
|
|
|
|
|
// 64px for the Appbar, 48px for the ChapterCount Header
|
|
|
|
|
height: 'calc(100vh - 64px - 48px)',
|
|
|
|
|
margin: 0,
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
2023-03-23 13:59:32 +01:00
|
|
|
const actionsStrings: {
|
|
|
|
|
[key in 'download' | 'delete' | 'bookmark' | 'unbookmark' | 'mark_as_read' | 'mark_as_unread']: {
|
|
|
|
|
success: TranslationKey;
|
|
|
|
|
error: TranslationKey;
|
|
|
|
|
};
|
|
|
|
|
} = {
|
2022-11-21 01:33:21 +01:00
|
|
|
download: {
|
2023-03-23 13:59:32 +01:00
|
|
|
success: 'chapter.action.download.add.label.success',
|
|
|
|
|
error: 'chapter.action.download.add.label.error',
|
2022-11-21 01:33:21 +01:00
|
|
|
},
|
|
|
|
|
delete: {
|
2023-03-23 13:59:32 +01:00
|
|
|
success: 'chapter.action.download.delete.label.success',
|
|
|
|
|
error: 'chapter.action.download.delete.label.error',
|
2022-11-21 01:33:21 +01:00
|
|
|
},
|
|
|
|
|
bookmark: {
|
2023-03-23 13:59:32 +01:00
|
|
|
success: 'chapter.action.bookmark.add.label.success',
|
|
|
|
|
error: 'chapter.action.bookmark.add.label.error',
|
2022-11-21 01:33:21 +01:00
|
|
|
},
|
|
|
|
|
unbookmark: {
|
2023-03-23 13:59:32 +01:00
|
|
|
success: 'chapter.action.bookmark.remove.label.success',
|
|
|
|
|
error: 'chapter.action.bookmark.remove.label.error',
|
2022-11-21 01:33:21 +01:00
|
|
|
},
|
|
|
|
|
mark_as_read: {
|
2023-03-23 13:59:32 +01:00
|
|
|
success: 'chapter.action.mark_as_read.add.label.success',
|
|
|
|
|
error: 'chapter.action.mark_as_read.add.label.error',
|
2022-11-21 01:33:21 +01:00
|
|
|
},
|
|
|
|
|
mark_as_unread: {
|
2023-03-23 13:59:32 +01:00
|
|
|
success: 'chapter.action.mark_as_read.remove.label.success',
|
|
|
|
|
error: 'chapter.action.mark_as_read.remove.label.error',
|
2022-11-21 01:33:21 +01:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export interface IChapterWithMeta {
|
2023-10-15 16:03:08 +02:00
|
|
|
chapter: TChapter;
|
2023-10-04 22:34:32 +02:00
|
|
|
downloadChapter: DownloadType | undefined;
|
2023-02-06 10:06:33 +01:00
|
|
|
selected: boolean | null;
|
2022-11-21 01:33:21 +01:00
|
|
|
}
|
|
|
|
|
|
2022-11-09 18:09:15 +01:00
|
|
|
interface IProps {
|
2023-10-15 16:03:08 +02:00
|
|
|
manga: TManga;
|
2023-09-30 16:55:35 +02:00
|
|
|
isRefreshing: boolean;
|
2022-11-09 18:09:15 +01:00
|
|
|
}
|
|
|
|
|
|
2023-10-28 00:32:02 +02:00
|
|
|
export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
|
2023-03-23 13:59:32 +01:00
|
|
|
const { t } = useTranslation();
|
|
|
|
|
|
2023-10-04 22:34:32 +02:00
|
|
|
const { data: downloaderData } = requestManager.useDownloadSubscription();
|
|
|
|
|
const queue = (downloaderData?.downloadChanged.queue as DownloadType[]) ?? [];
|
2022-11-09 18:09:15 +01:00
|
|
|
|
2023-09-30 16:55:35 +02:00
|
|
|
const [options, dispatch] = useChapterOptions(manga.id);
|
2023-12-16 19:00:05 +01:00
|
|
|
const { data: chaptersData, loading: isLoading } = requestManager.useGetMangaChapters(manga.id);
|
2023-10-15 16:03:08 +02:00
|
|
|
const chapters = useMemo(() => chaptersData?.chapters.nodes ?? [], [chaptersData?.chapters.nodes]);
|
2023-11-19 21:17:35 +01:00
|
|
|
|
2023-12-25 21:20:00 +01:00
|
|
|
const { areNoItemsSelected, areAllItemsSelected, selectedItemIds, handleSelectAll, handleSelection } =
|
2023-12-25 21:37:45 +01:00
|
|
|
useSelectableCollection(chapters.length, { currentKey: 'default' });
|
2023-12-25 21:20:00 +01:00
|
|
|
|
2023-11-19 21:17:35 +01:00
|
|
|
const { settings: metadataServerSettings } = useMetadataServerSettings();
|
2022-11-09 18:09:15 +01:00
|
|
|
|
2023-09-30 16:55:35 +02:00
|
|
|
const visibleChapters = useMemo(() => filterAndSortChapters(chapters, options), [chapters, options]);
|
2022-11-09 18:09:15 +01:00
|
|
|
|
2023-09-30 16:55:35 +02:00
|
|
|
const nextChapterIndexToRead = (manga.lastReadChapter?.sourceOrder ?? 0) + 1;
|
|
|
|
|
const isLatestChapterRead = manga.chapters.totalCount === manga.lastReadChapter?.sourceOrder;
|
2022-11-09 18:09:15 +01:00
|
|
|
|
2023-12-18 22:25:25 +01:00
|
|
|
const areAllChaptersRead = manga.unreadCount === 0;
|
2023-12-18 22:13:43 +01:00
|
|
|
const areAllChaptersDownloaded = manga.downloadCount === manga.chapters.totalCount;
|
|
|
|
|
|
2023-12-25 21:20:00 +01:00
|
|
|
const handleFabAction: ComponentProps<typeof ChapterSelectionFABActionItems>['onAction'] = (
|
|
|
|
|
action,
|
|
|
|
|
actionChapters,
|
|
|
|
|
) => {
|
2022-11-21 01:33:21 +01:00
|
|
|
if (actionChapters.length === 0) return;
|
2023-12-18 22:13:43 +01:00
|
|
|
const chapterIds = actionChapters
|
|
|
|
|
.filter(({ chapter }) => {
|
|
|
|
|
switch (action) {
|
|
|
|
|
case 'download':
|
|
|
|
|
return !chapter.isDownloaded;
|
|
|
|
|
case 'delete':
|
|
|
|
|
return chapter.isDownloaded;
|
|
|
|
|
case 'bookmark':
|
|
|
|
|
return !chapter.isBookmarked;
|
|
|
|
|
case 'unbookmark':
|
|
|
|
|
return chapter.isBookmarked;
|
|
|
|
|
case 'mark_as_read':
|
|
|
|
|
return !chapter.isRead;
|
|
|
|
|
case 'mark_as_unread':
|
|
|
|
|
return chapter.isRead;
|
|
|
|
|
default:
|
|
|
|
|
throw new Error(`ChapterList::handleFabAction: unknown action "${action}"`);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.map(({ chapter }) => chapter.id);
|
2022-11-21 01:33:21 +01:00
|
|
|
|
|
|
|
|
let actionPromise: Promise<any>;
|
2022-11-09 18:09:15 +01:00
|
|
|
|
|
|
|
|
if (action === 'download') {
|
2023-05-23 13:27:38 +02:00
|
|
|
actionPromise = requestManager.addChaptersToDownloadQueue(chapterIds).response;
|
2022-11-21 01:33:21 +01:00
|
|
|
} else {
|
2023-09-01 20:36:40 +02:00
|
|
|
const change: UpdateChapterPatchInput = {};
|
2022-11-21 01:33:21 +01:00
|
|
|
|
2023-09-01 20:36:40 +02:00
|
|
|
if (action === 'bookmark') change.isBookmarked = true;
|
2022-11-21 01:33:21 +01:00
|
|
|
else if (action === 'unbookmark') change.isBookmarked = false;
|
2023-04-12 18:37:23 +02:00
|
|
|
else if (action === 'mark_as_read' || action === 'mark_as_unread') {
|
|
|
|
|
change.isRead = action === 'mark_as_read';
|
|
|
|
|
change.lastPageRead = 0;
|
|
|
|
|
}
|
2022-11-21 01:33:21 +01:00
|
|
|
|
2023-09-01 20:36:40 +02:00
|
|
|
if (action === 'delete') {
|
|
|
|
|
actionPromise = requestManager.deleteDownloadedChapters(chapterIds).response;
|
|
|
|
|
} else {
|
2023-11-24 03:17:49 +01:00
|
|
|
const shouldDeleteChapters =
|
|
|
|
|
action === 'mark_as_read' && metadataServerSettings.deleteChaptersManuallyMarkedRead;
|
|
|
|
|
const chapterIdsToDelete = shouldDeleteChapters
|
|
|
|
|
? actionChapters
|
|
|
|
|
.filter(
|
|
|
|
|
({ chapter }) =>
|
|
|
|
|
chapter.isDownloaded &&
|
|
|
|
|
(!chapter.isBookmarked || metadataServerSettings.deleteChaptersWithBookmark),
|
|
|
|
|
)
|
|
|
|
|
.map(({ chapter }) => chapter.id)
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
actionPromise = requestManager.updateChapters(chapterIds, { ...change, chapterIdsToDelete }).response;
|
2023-11-19 21:17:35 +01:00
|
|
|
}
|
2022-11-09 18:09:15 +01:00
|
|
|
}
|
2022-11-21 01:33:21 +01:00
|
|
|
|
|
|
|
|
actionPromise
|
2023-07-04 21:53:25 +02:00
|
|
|
.then(() => makeToast(t(actionsStrings[action].success, { count: chapterIds.length }), 'success'))
|
|
|
|
|
.catch(() => makeToast(t(actionsStrings[action].error, { count: chapterIds.length }), 'error'));
|
2022-11-09 18:09:15 +01:00
|
|
|
};
|
|
|
|
|
|
2023-09-30 16:55:35 +02:00
|
|
|
const noChaptersFound = chapters.length === 0;
|
|
|
|
|
const noChaptersMatchingFilter = !noChaptersFound && visibleChapters.length === 0;
|
|
|
|
|
|
|
|
|
|
const chaptersWithMeta: IChapterWithMeta[] = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
visibleChapters.map((chapter) => {
|
|
|
|
|
const downloadChapter = queue?.find(
|
2023-10-04 22:34:32 +02:00
|
|
|
(cd) => cd.chapter.sourceOrder === chapter.sourceOrder && cd.chapter.manga.id === chapter.manga.id,
|
2023-09-30 16:55:35 +02:00
|
|
|
);
|
2023-12-25 21:20:00 +01:00
|
|
|
const selected = !areNoItemsSelected ? selectedItemIds.includes(chapter.id) : null;
|
2023-09-30 16:55:35 +02:00
|
|
|
return {
|
|
|
|
|
chapter,
|
|
|
|
|
downloadChapter,
|
|
|
|
|
selected,
|
|
|
|
|
};
|
|
|
|
|
}),
|
2023-12-25 21:20:00 +01:00
|
|
|
[queue, selectedItemIds, visibleChapters],
|
2023-09-30 16:55:35 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const chapterListFAB = useMemo(() => {
|
2023-12-25 21:20:00 +01:00
|
|
|
const selectedChapters = chaptersWithMeta.filter((chapter) => chapter.selected);
|
|
|
|
|
|
|
|
|
|
if (selectedChapters.length) {
|
|
|
|
|
return (
|
|
|
|
|
<SelectionFAB selectedItemsCount={selectedChapters.length} title="chapter.title">
|
|
|
|
|
{(handleClose) => (
|
|
|
|
|
<ChapterSelectionFABActionItems
|
|
|
|
|
selectedChapters={selectedChapters}
|
|
|
|
|
onAction={handleFabAction}
|
|
|
|
|
handleClose={handleClose}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</SelectionFAB>
|
|
|
|
|
);
|
2023-09-30 16:55:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isLatestChapterRead) {
|
|
|
|
|
return <ResumeFab chapterIndex={nextChapterIndexToRead} mangaId={manga.id} />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
2023-12-25 21:20:00 +01:00
|
|
|
}, [chaptersWithMeta, isLatestChapterRead]);
|
2023-09-30 16:55:35 +02:00
|
|
|
|
|
|
|
|
if (isLoading || (noChaptersFound && isRefreshing)) {
|
2022-11-09 18:09:15 +01:00
|
|
|
return (
|
2023-02-06 10:06:33 +01:00
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
margin: '10px auto',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
}}
|
2022-11-09 18:09:15 +01:00
|
|
|
>
|
|
|
|
|
<CircularProgress thickness={5} />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<Stack direction="column" sx={{ position: 'relative' }}>
|
2023-12-18 21:40:56 +01:00
|
|
|
<ChapterListHeader direction="row" alignItems="center" justifyContent="space-between">
|
2022-11-09 18:09:15 +01:00
|
|
|
<Typography variant="h5">
|
2023-03-23 13:59:32 +01:00
|
|
|
{`${visibleChapters.length} ${t('chapter.title', {
|
|
|
|
|
count: visibleChapters.length,
|
|
|
|
|
})}`}
|
2022-11-09 18:09:15 +01:00
|
|
|
</Typography>
|
|
|
|
|
|
2023-12-18 21:40:56 +01:00
|
|
|
<Stack direction="row" sx={{ paddingRight: '24px' }}>
|
2023-12-18 22:25:25 +01:00
|
|
|
<Tooltip title={t('chapter.action.mark_as_read.add.label.action.current')}>
|
|
|
|
|
<IconButton
|
|
|
|
|
disabled={areAllChaptersRead}
|
|
|
|
|
onClick={() => handleFabAction('mark_as_read', chaptersWithMeta)}
|
|
|
|
|
>
|
|
|
|
|
<DoneAllIcon />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</Tooltip>
|
2023-12-18 22:13:43 +01:00
|
|
|
<Tooltip title={t('chapter.action.download.add.label.action')}>
|
|
|
|
|
<IconButton
|
|
|
|
|
disabled={areAllChaptersDownloaded}
|
|
|
|
|
onClick={() => handleFabAction('download', chaptersWithMeta)}
|
|
|
|
|
>
|
|
|
|
|
<DownloadIcon />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</Tooltip>
|
2022-11-09 18:09:15 +01:00
|
|
|
<ChaptersToolbarMenu options={options} optionsDispatch={dispatch} />
|
2023-12-25 21:20:00 +01:00
|
|
|
<SelectableCollectionSelectAll
|
|
|
|
|
areAllItemsSelected={areAllItemsSelected}
|
|
|
|
|
areNoItemsSelected={areNoItemsSelected}
|
|
|
|
|
onChange={(checked) =>
|
|
|
|
|
handleSelectAll(checked, checked ? chapters.map((chapter) => chapter.id) : [])
|
|
|
|
|
}
|
|
|
|
|
/>
|
2023-12-18 21:40:56 +01:00
|
|
|
</Stack>
|
|
|
|
|
</ChapterListHeader>
|
2022-11-09 18:09:15 +01:00
|
|
|
|
2023-03-23 13:59:32 +01:00
|
|
|
{noChaptersFound && <EmptyView message={t('chapter.error.label.no_chapter_found')} />}
|
|
|
|
|
{noChaptersMatchingFilter && <EmptyView message={t('chapter.error.label.no_matches')} />}
|
2022-11-09 18:09:15 +01:00
|
|
|
|
|
|
|
|
<StyledVirtuoso
|
2023-02-06 10:06:33 +01:00
|
|
|
style={{
|
|
|
|
|
// override Virtuoso default values and set them with class
|
2022-11-09 18:09:15 +01:00
|
|
|
height: 'undefined',
|
|
|
|
|
// 900 is the md breakpoint in MUI
|
|
|
|
|
overflowY: window.innerWidth < 900 ? 'visible' : 'auto',
|
|
|
|
|
}}
|
2023-12-25 21:15:31 +01:00
|
|
|
components={{ Footer: () => <Box sx={{ paddingBottom: DEFAULT_FULL_FAB_HEIGHT }} /> }}
|
|
|
|
|
totalCount={visibleChapters.length}
|
|
|
|
|
itemContent={(index: number) => (
|
|
|
|
|
<ChapterCard
|
|
|
|
|
{...chaptersWithMeta[index]}
|
|
|
|
|
allChapters={chapters}
|
|
|
|
|
showChapterNumber={options.showChapterNumber}
|
2023-12-25 21:20:00 +01:00
|
|
|
onSelect={(selected) => handleSelection(chaptersWithMeta[index].chapter.id, selected)}
|
2023-12-25 21:15:31 +01:00
|
|
|
/>
|
|
|
|
|
)}
|
2022-11-09 18:09:15 +01:00
|
|
|
useWindowScroll={window.innerWidth < 900}
|
|
|
|
|
overscan={window.innerHeight * 0.5}
|
|
|
|
|
/>
|
|
|
|
|
</Stack>
|
2023-09-30 16:55:35 +02:00
|
|
|
{chapterListFAB}
|
2022-11-09 18:09:15 +01:00
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|