Files
suwayomi-material-you-webui/src/components/manga/ChapterList.tsx
schroda 8dae72604f Update to SWR version 2.x (#296)
* Update SWR to version 2.1.5

* Improve generic type names

* Use "isLoading" property of "useSWR" response

* Correctly load library without selected tab

With the latest SWR version the library was empty on the first load.
Not sure what exactly is the reason, but due to "isPaused" the request wasn't sent even after "activeTab" wasn't undefined anymore.
Instead of using "isPaused", passing "null" as the key will also prevent SWR from doing the request.
2023-05-15 11:56:55 +02:00

278 lines
10 KiB
TypeScript

/*
* 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 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';
import { BatchChaptersChange, IChapter, IDownloadChapter, IQueue, TranslationKey } from 'typings';
import { useTranslation } from 'react-i18next';
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: {
[key in 'download' | 'delete' | 'bookmark' | 'unbookmark' | 'mark_as_read' | 'mark_as_unread']: {
success: TranslationKey;
error: TranslationKey;
};
} = {
download: {
success: 'chapter.action.download.add.label.success',
error: 'chapter.action.download.add.label.error',
},
delete: {
success: 'chapter.action.download.delete.label.success',
error: 'chapter.action.download.delete.label.error',
},
bookmark: {
success: 'chapter.action.bookmark.add.label.success',
error: 'chapter.action.bookmark.add.label.error',
},
unbookmark: {
success: 'chapter.action.bookmark.remove.label.success',
error: 'chapter.action.bookmark.remove.label.error',
},
mark_as_read: {
success: 'chapter.action.mark_as_read.add.label.success',
error: 'chapter.action.mark_as_read.add.label.error',
},
mark_as_unread: {
success: 'chapter.action.mark_as_read.remove.label.success',
error: 'chapter.action.mark_as_read.remove.label.error',
},
};
export interface IChapterWithMeta {
chapter: IChapter;
downloadChapter: IDownloadChapter | undefined;
selected: boolean | null;
}
interface IProps {
mangaId: string;
}
const ChapterList: React.FC<IProps> = ({ mangaId }) => {
const { t } = useTranslation();
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,
isLoading,
} = 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(
() =>
chapters
.slice()
.reverse()
.find((chapter) => !chapter.read),
[chapters],
);
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' || action === 'mark_as_unread') {
change.isRead = action === 'mark_as_read';
change.lastPageRead = 0;
}
actionPromise = client.post('/api/v1/chapter/batch', { chapterIds, change });
}
actionPromise
.then(() => makeToast(t(actionsStrings[action].success, { count: chapterIds.length }) as string, 'success'))
.then(() => mutate())
.catch(() => makeToast(t(actionsStrings[action].error, { count: chapterIds.length }) as string, 'error'));
};
if (isLoading) {
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} ${t('chapter.title', {
count: visibleChapters.length,
})}`}
</Typography>
{selection === null ? (
<ChaptersToolbarMenu options={options} optionsDispatch={dispatch} />
) : (
<Stack direction="row">
<Button size="small" onClick={handleSelectAll}>
{t('global.button.select_all')}
</Button>
<Button size="small" onClick={handleClear}>
{t('global.button.clear')}
</Button>
</Stack>
)}
</Stack>
{noChaptersFound && <EmptyView message={t('chapter.error.label.no_chapter_found')} />}
{noChaptersMatchingFilter && <EmptyView message={t('chapter.error.label.no_matches')} />}
<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;