Feature/batch chapter actions (#194)

* Add batch delete chapters

* Add remaining batch actions
This commit is contained in:
Valter Martinek
2022-11-21 01:33:21 +01:00
committed by GitHub
parent 1510c5a093
commit 68f67273aa
5 changed files with 180 additions and 37 deletions

View File

@@ -15,9 +15,10 @@ import ChapterCard from 'components/manga/ChapterCard';
import ResumeFab from 'components/manga/ResumeFAB'; import ResumeFab from 'components/manga/ResumeFAB';
import { filterAndSortChapters, useChapterOptions } from 'components/manga/util'; import { filterAndSortChapters, useChapterOptions } from 'components/manga/util';
import EmptyView from 'components/util/EmptyView'; import EmptyView from 'components/util/EmptyView';
import { pluralize } from 'components/util/helpers'; import { interpolate } from 'components/util/helpers';
import makeToast from 'components/util/Toast'; import makeToast from 'components/util/Toast';
import React, { import React, {
ComponentProps,
useEffect, useMemo, useRef, useState, useEffect, useMemo, useRef, useState,
} from 'react'; } from 'react';
import { Virtuoso } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso';
@@ -37,6 +38,39 @@ const StyledVirtuoso = styled(Virtuoso)(({ theme }) => ({
}, },
})); }));
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 { interface IProps {
mangaId: string mangaId: string
} }
@@ -81,11 +115,6 @@ const ChapterList: React.FC<IProps> = ({ mangaId }) => {
.find((c) => c.read === false), .find((c) => c.read === false),
[visibleChapters]); [visibleChapters]);
const selectedChapters = useMemo(() => {
if (selection === null) return null;
return visibleChapters.filter((chap) => selection.includes(chap.id));
}, [visibleChapters, selection]);
const handleSelection = (index: number) => { const handleSelection = (index: number) => {
const chapter = visibleChapters[index]; const chapter = visibleChapters[index];
if (!chapter) return; if (!chapter) return;
@@ -110,16 +139,30 @@ const ChapterList: React.FC<IProps> = ({ mangaId }) => {
setSelection(null); setSelection(null);
}; };
const handleFabAction = (action: 'download') => { const handleFabAction: ComponentProps<typeof SelectionFAB>['onAction'] = (action, actionChapters) => {
if (!selectedChapters || selectedChapters.length === 0) return; if (actionChapters.length === 0) return;
const chapterIds = selectedChapters.map((c) => c.id); const chapterIds = actionChapters.map(({ chapter }) => chapter.id);
let actionPromise: Promise<any>;
if (action === 'download') { if (action === 'download') {
client.post('/api/v1/download/batch', { chapterIds }) actionPromise = client.post('/api/v1/download/batch', { chapterIds });
.then(() => makeToast(`${chapterIds.length} ${pluralize(chapterIds.length, 'download')} added`, 'success')) } else {
.then(() => mutate()) const change: BatchChaptersChange = {};
.catch(() => makeToast('Error adding downloads', 'error'));
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) { if (loading) {
@@ -138,7 +181,7 @@ const ChapterList: React.FC<IProps> = ({ mangaId }) => {
const noChaptersFound = chapters.length === 0; const noChaptersFound = chapters.length === 0;
const noChaptersMatchingFilter = !noChaptersFound && visibleChapters.length === 0; const noChaptersMatchingFilter = !noChaptersFound && visibleChapters.length === 0;
const scrollCache = visibleChapters.map((chapter) => { const chaptersWithMeta: IChapterWithMeta[] = visibleChapters.map((chapter) => {
const downloadChapter = queue?.find( const downloadChapter = queue?.find(
(cd) => cd.chapterIndex === chapter.index (cd) => cd.chapterIndex === chapter.index
&& cd.mangaId === chapter.mangaId, && cd.mangaId === chapter.mangaId,
@@ -151,6 +194,10 @@ const ChapterList: React.FC<IProps> = ({ mangaId }) => {
}; };
}); });
const selectedChapters = (selection === null)
? null
: chaptersWithMeta.filter(({ chapter }) => selection.includes(chapter.id));
return ( return (
<> <>
<Stack direction="column" sx={{ position: 'relative' }}> <Stack direction="column" sx={{ position: 'relative' }}>
@@ -193,7 +240,7 @@ const ChapterList: React.FC<IProps> = ({ mangaId }) => {
itemContent={(index:number) => ( itemContent={(index:number) => (
<ChapterCard <ChapterCard
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
{...scrollCache[index]} {...chaptersWithMeta[index]}
showChapterNumber={options.showChapterNumber} showChapterNumber={options.showChapterNumber}
triggerChaptersUpdate={() => mutate()} triggerChaptersUpdate={() => mutate()}
onSelect={() => handleSelection(index)} onSelect={() => handleSelection(index)}

View File

@@ -5,18 +5,21 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import Download from '@mui/icons-material/Download';
import MoreHoriz from '@mui/icons-material/MoreHoriz'; import MoreHoriz from '@mui/icons-material/MoreHoriz';
import { import {
Fab, ListItemIcon, ListItemText, Menu, MenuItem, Fab, Menu,
} from '@mui/material'; } from '@mui/material';
import { Box } from '@mui/system'; import { Box } from '@mui/system';
import { pluralize } from 'components/util/helpers'; import { pluralize } from 'components/util/helpers';
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import type { IChapterWithMeta } from './ChapterList';
import SelectionFABActionItem from './SelectionFABActionItem';
export type SelectionAction = 'download' | 'delete' | 'bookmark' | 'unbookmark' | 'mark_as_read' | 'mark_as_unread';
interface SelectionFABProps{ interface SelectionFABProps{
selectedChapters: IChapter[] selectedChapters: IChapterWithMeta[]
onAction: (action: 'download') => void onAction: (action: SelectionAction, chapters: IChapterWithMeta[]) => void
} }
const SelectionFAB: React.FC<SelectionFABProps> = (props) => { const SelectionFAB: React.FC<SelectionFABProps> = (props) => {
@@ -27,6 +30,11 @@ const SelectionFAB: React.FC<SelectionFABProps> = (props) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const handleClose = () => setOpen(false); const handleClose = () => setOpen(false);
const handleAction = (action: SelectionAction, chapters: IChapterWithMeta[]) => {
onAction(action, chapters);
handleClose();
};
return ( return (
<Box <Box
sx={{ sx={{
@@ -54,24 +62,44 @@ const SelectionFAB: React.FC<SelectionFABProps> = (props) => {
'aria-labelledby': 'selectionMenuButton', 'aria-labelledby': 'selectionMenuButton',
}} }}
> >
<MenuItem <SelectionFABActionItem
onClick={() => { onAction('download'); handleClose(); }} action="download"
> matchingChapters={selectedChapters.filter(
<ListItemIcon> ({ chapter: c, downloadChapter: dc }) => !c.downloaded && dc === undefined,
<Download fontSize="small" /> )}
</ListItemIcon> onClick={handleAction}
<ListItemText> title="Download selected"
Download selected />
</ListItemText> <SelectionFABActionItem
</MenuItem> action="delete"
{/* <MenuItem onClick={() => { onClearSelection(); handleClose(); }}> matchingChapters={selectedChapters.filter(({ chapter }) => chapter.downloaded)}
<ListItemIcon> onClick={handleAction}
<Clear fontSize="small" /> title="Delete selected"
</ListItemIcon> />
<ListItemText> <SelectionFABActionItem
ClearSelection action="bookmark"
</ListItemText> matchingChapters={selectedChapters.filter(({ chapter }) => !chapter.bookmarked)}
</MenuItem> */} onClick={handleAction}
title="Bookmark selected"
/>
<SelectionFABActionItem
action="unbookmark"
matchingChapters={selectedChapters.filter(({ chapter }) => chapter.bookmarked)}
onClick={handleAction}
title="Remove bookmarks from selected"
/>
<SelectionFABActionItem
action="mark_as_read"
matchingChapters={selectedChapters.filter(({ chapter }) => !chapter.read)}
onClick={handleAction}
title="Mark selected as read"
/>
<SelectionFABActionItem
action="mark_as_unread"
matchingChapters={selectedChapters.filter(({ chapter }) => chapter.read)}
onClick={handleAction}
title="Mark selected as unread"
/>
</Menu> </Menu>
</Box> </Box>
); );

View File

@@ -0,0 +1,56 @@
/*
* 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 BookmarkAdd from '@mui/icons-material/BookmarkAdd';
import BookmarkRemove from '@mui/icons-material/BookmarkRemove';
import Delete from '@mui/icons-material/Delete';
import Done from '@mui/icons-material/Done';
import Download from '@mui/icons-material/Download';
import RemoveDone from '@mui/icons-material/RemoveDone';
import { ListItemIcon, ListItemText, MenuItem } from '@mui/material';
import React from 'react';
import type { IChapterWithMeta } from './ChapterList';
import type { SelectionAction } from './SelectionFAB';
interface IProps {
action: SelectionAction
matchingChapters: IChapterWithMeta[]
title: string
onClick: (action: SelectionAction, chapters: IChapterWithMeta[]) => void
}
const ICONS = {
download: Download,
delete: Delete,
bookmark: BookmarkAdd,
unbookmark: BookmarkRemove,
mark_as_read: Done,
mark_as_unread: RemoveDone,
};
const SelectionFABActionItem: React.FC<IProps> = ({
action, matchingChapters, onClick, title,
}) => {
const count = matchingChapters.length;
const Icon = ICONS[action];
return (
<MenuItem
onClick={() => onClick(action, matchingChapters)}
disabled={count === 0}
>
<ListItemIcon>
<Icon fontSize="small" />
</ListItemIcon>
<ListItemText>
{title}
{count > 0 ? ` (${count})` : ''}
</ListItemText>
</MenuItem>
);
};
export default SelectionFABActionItem;

View File

@@ -12,3 +12,8 @@ export const pluralize = (count: number, input: string | { one: string, many: st
} }
return input[count === 1 ? 'one' : 'many']; return input[count === 1 ? 'one' : 'many'];
}; };
export const interpolate = (count: number, input: { one: string, many: string }) => {
const text = count === 1 ? input.one : input.many;
return text.replaceAll('%count%', count.toString());
};

7
src/typings.d.ts vendored
View File

@@ -289,3 +289,10 @@ interface LibraryOptions {
sorts: NullAndUndefined<string> sorts: NullAndUndefined<string>
sortDesc: NullAndUndefined<boolean> sortDesc: NullAndUndefined<boolean>
} }
interface BatchChaptersChange {
delete?: boolean
isRead?: boolean
isBookmarked?: boolean
lastPageRead?: number
}