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
|
|
|
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
|
|
|
|
2023-02-06 10:06:33 +01:00
|
|
|
import { Button, CircularProgress, Stack } from '@mui/material';
|
2022-11-09 18:09:15 +01:00
|
|
|
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';
|
2022-11-21 01:33:21 +01:00
|
|
|
import { interpolate } from 'components/util/helpers';
|
2022-11-09 18:09:15 +01:00
|
|
|
import makeToast from 'components/util/Toast';
|
2023-02-06 10:06:33 +01:00
|
|
|
import React, { ComponentProps, useEffect, useMemo, useRef, useState } from 'react';
|
2022-11-09 18:09:15 +01:00
|
|
|
import { Virtuoso } from 'react-virtuoso';
|
|
|
|
|
import client, { useQuery } from 'util/client';
|
2023-02-05 16:15:20 +01:00
|
|
|
import ChaptersToolbarMenu from 'components/manga/ChaptersToolbarMenu';
|
|
|
|
|
import SelectionFAB from 'components/manga/SelectionFAB';
|
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,
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
2022-11-21 01:33:21 +01:00
|
|
|
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 {
|
2023-02-06 10:06:33 +01:00
|
|
|
chapter: IChapter;
|
|
|
|
|
downloadChapter: IDownloadChapter | undefined;
|
|
|
|
|
selected: boolean | null;
|
2022-11-21 01:33:21 +01:00
|
|
|
}
|
|
|
|
|
|
2022-11-09 18:09:15 +01:00
|
|
|
interface IProps {
|
2023-02-06 10:06:33 +01:00
|
|
|
mangaId: string;
|
2022-11-09 18:09:15 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) => {
|
2023-02-06 10:06:33 +01:00
|
|
|
const prevChapterDownload = prevQueue.find(
|
|
|
|
|
(pcd) => cd.chapterIndex === pcd.chapterIndex && cd.mangaId === pcd.mangaId,
|
|
|
|
|
);
|
2022-11-09 18:09:15 +01:00
|
|
|
if (!prevChapterDownload) return true;
|
|
|
|
|
return cd.state !== prevChapterDownload.state;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (changedDownloads.length > 0) {
|
|
|
|
|
mutate();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
prevQueueRef.current = queue;
|
|
|
|
|
}, [queue]);
|
|
|
|
|
|
2023-02-06 10:06:33 +01:00
|
|
|
const visibleChapters = useMemo(
|
|
|
|
|
() => filterAndSortChapters(chapters, options), //
|
|
|
|
|
[chapters, options],
|
|
|
|
|
);
|
2022-11-09 18:09:15 +01:00
|
|
|
|
2023-02-06 10:06:33 +01:00
|
|
|
const firstUnreadChapter = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
visibleChapters
|
|
|
|
|
.slice()
|
|
|
|
|
.reverse()
|
|
|
|
|
.find((c) => c.read === false),
|
|
|
|
|
[visibleChapters],
|
|
|
|
|
);
|
2022-11-09 18:09:15 +01:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
2023-02-06 10:06:33 +01:00
|
|
|
const handleFabAction: ComponentProps<typeof SelectionFAB>['onAction'] = (
|
|
|
|
|
action,
|
|
|
|
|
actionChapters,
|
|
|
|
|
) => {
|
2022-11-21 01:33:21 +01:00
|
|
|
if (actionChapters.length === 0) return;
|
|
|
|
|
const chapterIds = actionChapters.map(({ chapter }) => chapter.id);
|
|
|
|
|
|
|
|
|
|
let actionPromise: Promise<any>;
|
2022-11-09 18:09:15 +01:00
|
|
|
|
|
|
|
|
if (action === 'download') {
|
2022-11-21 01:33:21 +01:00
|
|
|
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 });
|
2022-11-09 18:09:15 +01:00
|
|
|
}
|
2022-11-21 01:33:21 +01:00
|
|
|
|
|
|
|
|
actionPromise
|
2023-02-06 10:06:33 +01:00
|
|
|
.then(() =>
|
|
|
|
|
makeToast(
|
|
|
|
|
interpolate(chapterIds.length, actionsStrings[action].success),
|
|
|
|
|
'success',
|
|
|
|
|
),
|
|
|
|
|
)
|
2022-11-21 01:33:21 +01:00
|
|
|
.then(() => mutate())
|
2023-02-06 10:06:33 +01:00
|
|
|
.catch(() =>
|
|
|
|
|
makeToast(interpolate(chapterIds.length, actionsStrings[action].error), 'error'),
|
|
|
|
|
);
|
2022-11-09 18:09:15 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const noChaptersFound = chapters.length === 0;
|
|
|
|
|
const noChaptersMatchingFilter = !noChaptersFound && visibleChapters.length === 0;
|
|
|
|
|
|
2022-11-21 01:33:21 +01:00
|
|
|
const chaptersWithMeta: IChapterWithMeta[] = visibleChapters.map((chapter) => {
|
2022-11-09 18:09:15 +01:00
|
|
|
const downloadChapter = queue?.find(
|
2023-02-06 10:06:33 +01:00
|
|
|
(cd) => cd.chapterIndex === chapter.index && cd.mangaId === chapter.mangaId,
|
2022-11-09 18:09:15 +01:00
|
|
|
);
|
|
|
|
|
const selected = selection?.includes(chapter.id) ?? null;
|
|
|
|
|
return {
|
|
|
|
|
chapter,
|
|
|
|
|
downloadChapter,
|
|
|
|
|
selected,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
2023-02-06 10:06:33 +01:00
|
|
|
const selectedChapters =
|
|
|
|
|
selection === null
|
|
|
|
|
? null
|
|
|
|
|
: chaptersWithMeta.filter(({ chapter }) => selection.includes(chapter.id));
|
2022-11-21 01:33:21 +01:00
|
|
|
|
2022-11-09 18:09:15 +01:00
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<Stack direction="column" sx={{ position: 'relative' }}>
|
|
|
|
|
<Stack
|
|
|
|
|
direction="row"
|
|
|
|
|
alignItems="center"
|
|
|
|
|
justifyContent="space-between"
|
|
|
|
|
sx={{
|
2023-02-06 10:06:33 +01:00
|
|
|
m: 1,
|
|
|
|
|
mb: 0,
|
|
|
|
|
mr: 2,
|
|
|
|
|
minHeight: 40,
|
2022-11-09 18:09:15 +01:00
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Typography variant="h5">
|
2023-02-06 10:06:33 +01:00
|
|
|
{`${visibleChapters.length} Chapter${
|
|
|
|
|
visibleChapters.length === 1 ? '' : 's'
|
|
|
|
|
}`}
|
2022-11-09 18:09:15 +01:00
|
|
|
</Typography>
|
|
|
|
|
|
|
|
|
|
{selection === null ? (
|
|
|
|
|
<ChaptersToolbarMenu options={options} optionsDispatch={dispatch} />
|
|
|
|
|
) : (
|
|
|
|
|
<Stack direction="row">
|
2023-02-06 10:06:33 +01:00
|
|
|
<Button size="small" onClick={handleSelectAll}>
|
|
|
|
|
Select all
|
|
|
|
|
</Button>
|
|
|
|
|
<Button size="small" onClick={handleClear}>
|
|
|
|
|
Clear
|
|
|
|
|
</Button>
|
2022-11-09 18:09:15 +01:00
|
|
|
</Stack>
|
|
|
|
|
)}
|
|
|
|
|
</Stack>
|
|
|
|
|
|
2023-02-06 10:06:33 +01:00
|
|
|
{noChaptersFound && <EmptyView message="No chapters found" />}
|
|
|
|
|
{noChaptersMatchingFilter && <EmptyView message="No chapters matching filter" />}
|
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',
|
|
|
|
|
}}
|
|
|
|
|
totalCount={visibleChapters.length}
|
2023-02-06 10:06:33 +01:00
|
|
|
itemContent={(index: number) => (
|
2022-11-09 18:09:15 +01:00
|
|
|
<ChapterCard
|
2022-11-21 01:33:21 +01:00
|
|
|
{...chaptersWithMeta[index]}
|
2022-11-09 18:09:15 +01:00
|
|
|
showChapterNumber={options.showChapterNumber}
|
|
|
|
|
triggerChaptersUpdate={() => mutate()}
|
|
|
|
|
onSelect={() => handleSelection(index)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
useWindowScroll={window.innerWidth < 900}
|
|
|
|
|
overscan={window.innerHeight * 0.5}
|
|
|
|
|
/>
|
|
|
|
|
</Stack>
|
|
|
|
|
{selectedChapters !== null ? (
|
2023-02-06 10:06:33 +01:00
|
|
|
<SelectionFAB selectedChapters={selectedChapters} onAction={handleFabAction} />
|
2022-11-09 18:09:15 +01:00
|
|
|
) : (
|
|
|
|
|
firstUnreadChapter && <ResumeFab chapter={firstUnreadChapter} mangaId={mangaId} />
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default ChapterList;
|