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

280 lines
10 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 { Button, CircularProgress, Stack } from '@mui/material';
import Typography from '@mui/material/Typography';
import { styled } from '@mui/system';
import useSubscription from 'components/library/useSubscription';
import ChapterCard from 'components/manga/ChapterCard';
import ResumeFab from 'components/manga/ResumeFAB';
import { filterAndSortChapters, useChapterOptions } from 'components/manga/util';
import EmptyView from 'components/util/EmptyView';
import { interpolate } from 'components/util/helpers';
import makeToast from 'components/util/Toast';
import React, { ComponentProps, useEffect, useMemo, useRef, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import client, { useQuery } from 'util/client';
import ChaptersToolbarMenu from 'components/manga/ChaptersToolbarMenu';
import SelectionFAB from 'components/manga/SelectionFAB';
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,
},
}));
const actionsStrings = {
download: {
success: { one: 'Download added', many: '%count% downloads added' },
error: { one: 'Error adding download', many: 'Error adding downloads' },
},
delete: {
success: { one: 'Chapter deleted', many: '%count% chapters deleted' },
error: { one: 'Error deleting chapter', many: 'Error deleting chapters' },
},
bookmark: {
success: { one: 'Chapter bookmarked', many: '%count% chapters bookmarked' },
error: { one: 'Error bookmarking chapter', many: 'Error bookmarking chapters' },
},
unbookmark: {
success: { one: 'Chapter bookmark removed', many: '%count% chapter bookmarks removed' },
error: { one: 'Error removing bookmark', many: 'Error removing bookmarks' },
},
mark_as_read: {
success: { one: 'Chapter marked as read', many: '%count% chapters marked as read' },
error: { one: 'Error marking chapter as read', many: 'Error marking chapters as read' },
},
mark_as_unread: {
success: { one: 'Chapter marked as unread', many: '%count% chapters marked as unread' },
error: { one: 'Error marking chapter as unread', many: 'Error marking chapters as unread' },
},
};
export interface IChapterWithMeta {
chapter: IChapter;
downloadChapter: IDownloadChapter | undefined;
selected: boolean | null;
}
interface IProps {
mangaId: string;
}
const ChapterList: React.FC<IProps> = ({ mangaId }) => {
const [selection, setSelection] = useState<number[] | null>(null);
const prevQueueRef = useRef<IDownloadChapter[]>();
const queue = useSubscription<IQueue>('/api/v1/downloads').data?.queue;
const [options, dispatch] = useChapterOptions(mangaId);
const {
data: chaptersData,
mutate,
loading,
} = useQuery<IChapter[]>(`/api/v1/manga/${mangaId}/chapters?onlineFetch=false`);
const chapters = useMemo(() => chaptersData ?? [], [chaptersData]);
useEffect(() => {
if (prevQueueRef.current && queue) {
const prevQueue = prevQueueRef.current;
const changedDownloads = queue.filter((cd) => {
const prevChapterDownload = prevQueue.find(
(pcd) => cd.chapterIndex === pcd.chapterIndex && cd.mangaId === pcd.mangaId,
);
if (!prevChapterDownload) return true;
return cd.state !== prevChapterDownload.state;
});
if (changedDownloads.length > 0) {
mutate();
}
}
prevQueueRef.current = queue;
}, [queue]);
const visibleChapters = useMemo(
() => filterAndSortChapters(chapters, options), //
[chapters, options],
);
const firstUnreadChapter = useMemo(
() =>
visibleChapters
.slice()
.reverse()
.find((c) => c.read === false),
[visibleChapters],
);
const handleSelection = (index: number) => {
const chapter = visibleChapters[index];
if (!chapter) return;
if (selection === null) {
setSelection([chapter.id]);
} else if (selection.includes(chapter.id)) {
const newSelection = selection.filter((cid) => cid !== chapter.id);
setSelection(newSelection.length > 0 ? newSelection : null);
} else {
setSelection([...selection, chapter.id]);
}
};
const handleSelectAll = () => {
if (selection === null) return;
setSelection(visibleChapters.map((c) => c.id));
};
const handleClear = () => {
if (selection === null) return;
setSelection(null);
};
const handleFabAction: ComponentProps<typeof SelectionFAB>['onAction'] = (
action,
actionChapters,
) => {
if (actionChapters.length === 0) return;
const chapterIds = actionChapters.map(({ chapter }) => chapter.id);
let actionPromise: Promise<any>;
if (action === 'download') {
actionPromise = client.post('/api/v1/download/batch', { chapterIds });
} else {
const change: BatchChaptersChange = {};
if (action === 'delete') change.delete = true;
else if (action === 'bookmark') change.isBookmarked = true;
else if (action === 'unbookmark') change.isBookmarked = false;
else if (action === 'mark_as_read') change.isRead = true;
else if (action === 'mark_as_unread') change.isRead = false;
actionPromise = client.post('/api/v1/chapter/batch', { chapterIds, change });
}
actionPromise
.then(() =>
makeToast(
interpolate(chapterIds.length, actionsStrings[action].success),
'success',
),
)
.then(() => mutate())
.catch(() =>
makeToast(interpolate(chapterIds.length, actionsStrings[action].error), 'error'),
);
};
if (loading) {
return (
<div
style={{
margin: '10px auto',
display: 'flex',
justifyContent: 'center',
}}
>
<CircularProgress thickness={5} />
</div>
);
}
const noChaptersFound = chapters.length === 0;
const noChaptersMatchingFilter = !noChaptersFound && visibleChapters.length === 0;
const chaptersWithMeta: IChapterWithMeta[] = visibleChapters.map((chapter) => {
const downloadChapter = queue?.find(
(cd) => cd.chapterIndex === chapter.index && cd.mangaId === chapter.mangaId,
);
const selected = selection?.includes(chapter.id) ?? null;
return {
chapter,
downloadChapter,
selected,
};
});
const selectedChapters =
selection === null
? null
: chaptersWithMeta.filter(({ chapter }) => selection.includes(chapter.id));
return (
<>
<Stack direction="column" sx={{ position: 'relative' }}>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{
m: 1,
mb: 0,
mr: 2,
minHeight: 40,
}}
>
<Typography variant="h5">
{`${visibleChapters.length} Chapter${
visibleChapters.length === 1 ? '' : 's'
}`}
</Typography>
{selection === null ? (
<ChaptersToolbarMenu options={options} optionsDispatch={dispatch} />
) : (
<Stack direction="row">
<Button size="small" onClick={handleSelectAll}>
Select all
</Button>
<Button size="small" onClick={handleClear}>
Clear
</Button>
</Stack>
)}
</Stack>
{noChaptersFound && <EmptyView message="No chapters found" />}
{noChaptersMatchingFilter && <EmptyView message="No chapters matching filter" />}
<StyledVirtuoso
style={{
// override Virtuoso default values and set them with class
height: 'undefined',
// 900 is the md breakpoint in MUI
overflowY: window.innerWidth < 900 ? 'visible' : 'auto',
}}
totalCount={visibleChapters.length}
itemContent={(index: number) => (
<ChapterCard
{...chaptersWithMeta[index]}
showChapterNumber={options.showChapterNumber}
triggerChaptersUpdate={() => mutate()}
onSelect={() => handleSelection(index)}
/>
)}
useWindowScroll={window.innerWidth < 900}
overscan={window.innerHeight * 0.5}
/>
</Stack>
{selectedChapters !== null ? (
<SelectionFAB selectedChapters={selectedChapters} onAction={handleFabAction} />
) : (
firstUnreadChapter && <ResumeFab chapter={firstUnreadChapter} mangaId={mangaId} />
)}
</>
);
};
export default ChapterList;