Skip to content

Commit 68f6727

Browse files
authored
Feature/batch chapter actions (#194)
* Add batch delete chapters * Add remaining batch actions
1 parent 1510c5a commit 68f6727

File tree

5 files changed

+180
-37
lines changed

5 files changed

+180
-37
lines changed

src/components/manga/ChapterList.tsx

+62-15
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ import ChapterCard from 'components/manga/ChapterCard';
1515
import ResumeFab from 'components/manga/ResumeFAB';
1616
import { filterAndSortChapters, useChapterOptions } from 'components/manga/util';
1717
import EmptyView from 'components/util/EmptyView';
18-
import { pluralize } from 'components/util/helpers';
18+
import { interpolate } from 'components/util/helpers';
1919
import makeToast from 'components/util/Toast';
2020
import React, {
21+
ComponentProps,
2122
useEffect, useMemo, useRef, useState,
2223
} from 'react';
2324
import { Virtuoso } from 'react-virtuoso';
@@ -37,6 +38,39 @@ const StyledVirtuoso = styled(Virtuoso)(({ theme }) => ({
3738
},
3839
}));
3940

41+
const actionsStrings = {
42+
download: {
43+
success: { one: 'Download added', many: '%count% downloads added' },
44+
error: { one: 'Error adding download', many: 'Error adding downloads' },
45+
},
46+
delete: {
47+
success: { one: 'Chapter deleted', many: '%count% chapters deleted' },
48+
error: { one: 'Error deleting chapter', many: 'Error deleting chapters' },
49+
},
50+
bookmark: {
51+
success: { one: 'Chapter bookmarked', many: '%count% chapters bookmarked' },
52+
error: { one: 'Error bookmarking chapter', many: 'Error bookmarking chapters' },
53+
},
54+
unbookmark: {
55+
success: { one: 'Chapter bookmark removed', many: '%count% chapter bookmarks removed' },
56+
error: { one: 'Error removing bookmark', many: 'Error removing bookmarks' },
57+
},
58+
mark_as_read: {
59+
success: { one: 'Chapter marked as read', many: '%count% chapters marked as read' },
60+
error: { one: 'Error marking chapter as read', many: 'Error marking chapters as read' },
61+
},
62+
mark_as_unread: {
63+
success: { one: 'Chapter marked as unread', many: '%count% chapters marked as unread' },
64+
error: { one: 'Error marking chapter as unread', many: 'Error marking chapters as unread' },
65+
},
66+
};
67+
68+
export interface IChapterWithMeta {
69+
chapter: IChapter
70+
downloadChapter: IDownloadChapter | undefined
71+
selected: boolean | null
72+
}
73+
4074
interface IProps {
4175
mangaId: string
4276
}
@@ -81,11 +115,6 @@ const ChapterList: React.FC<IProps> = ({ mangaId }) => {
81115
.find((c) => c.read === false),
82116
[visibleChapters]);
83117

84-
const selectedChapters = useMemo(() => {
85-
if (selection === null) return null;
86-
return visibleChapters.filter((chap) => selection.includes(chap.id));
87-
}, [visibleChapters, selection]);
88-
89118
const handleSelection = (index: number) => {
90119
const chapter = visibleChapters[index];
91120
if (!chapter) return;
@@ -110,16 +139,30 @@ const ChapterList: React.FC<IProps> = ({ mangaId }) => {
110139
setSelection(null);
111140
};
112141

113-
const handleFabAction = (action: 'download') => {
114-
if (!selectedChapters || selectedChapters.length === 0) return;
115-
const chapterIds = selectedChapters.map((c) => c.id);
142+
const handleFabAction: ComponentProps<typeof SelectionFAB>['onAction'] = (action, actionChapters) => {
143+
if (actionChapters.length === 0) return;
144+
const chapterIds = actionChapters.map(({ chapter }) => chapter.id);
145+
146+
let actionPromise: Promise<any>;
116147

117148
if (action === 'download') {
118-
client.post('/api/v1/download/batch', { chapterIds })
119-
.then(() => makeToast(`${chapterIds.length} ${pluralize(chapterIds.length, 'download')} added`, 'success'))
120-
.then(() => mutate())
121-
.catch(() => makeToast('Error adding downloads', 'error'));
149+
actionPromise = client.post('/api/v1/download/batch', { chapterIds });
150+
} else {
151+
const change: BatchChaptersChange = {};
152+
153+
if (action === 'delete') change.delete = true;
154+
else if (action === 'bookmark') change.isBookmarked = true;
155+
else if (action === 'unbookmark') change.isBookmarked = false;
156+
else if (action === 'mark_as_read') change.isRead = true;
157+
else if (action === 'mark_as_unread') change.isRead = false;
158+
159+
actionPromise = client.post('/api/v1/chapter/batch', { chapterIds, change });
122160
}
161+
162+
actionPromise
163+
.then(() => makeToast(interpolate(chapterIds.length, actionsStrings[action].success), 'success'))
164+
.then(() => mutate())
165+
.catch(() => makeToast(interpolate(chapterIds.length, actionsStrings[action].error), 'error'));
123166
};
124167

125168
if (loading) {
@@ -138,7 +181,7 @@ const ChapterList: React.FC<IProps> = ({ mangaId }) => {
138181
const noChaptersFound = chapters.length === 0;
139182
const noChaptersMatchingFilter = !noChaptersFound && visibleChapters.length === 0;
140183

141-
const scrollCache = visibleChapters.map((chapter) => {
184+
const chaptersWithMeta: IChapterWithMeta[] = visibleChapters.map((chapter) => {
142185
const downloadChapter = queue?.find(
143186
(cd) => cd.chapterIndex === chapter.index
144187
&& cd.mangaId === chapter.mangaId,
@@ -151,6 +194,10 @@ const ChapterList: React.FC<IProps> = ({ mangaId }) => {
151194
};
152195
});
153196

197+
const selectedChapters = (selection === null)
198+
? null
199+
: chaptersWithMeta.filter(({ chapter }) => selection.includes(chapter.id));
200+
154201
return (
155202
<>
156203
<Stack direction="column" sx={{ position: 'relative' }}>
@@ -193,7 +240,7 @@ const ChapterList: React.FC<IProps> = ({ mangaId }) => {
193240
itemContent={(index:number) => (
194241
<ChapterCard
195242
// eslint-disable-next-line react/jsx-props-no-spreading
196-
{...scrollCache[index]}
243+
{...chaptersWithMeta[index]}
197244
showChapterNumber={options.showChapterNumber}
198245
triggerChaptersUpdate={() => mutate()}
199246
onSelect={() => handleSelection(index)}

src/components/manga/SelectionFAB.tsx

+50-22
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,21 @@
55
* License, v. 2.0. If a copy of the MPL was not distributed with this
66
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
77

8-
import Download from '@mui/icons-material/Download';
98
import MoreHoriz from '@mui/icons-material/MoreHoriz';
109
import {
11-
Fab, ListItemIcon, ListItemText, Menu, MenuItem,
10+
Fab, Menu,
1211
} from '@mui/material';
1312
import { Box } from '@mui/system';
1413
import { pluralize } from 'components/util/helpers';
1514
import React, { useRef, useState } from 'react';
15+
import type { IChapterWithMeta } from './ChapterList';
16+
import SelectionFABActionItem from './SelectionFABActionItem';
17+
18+
export type SelectionAction = 'download' | 'delete' | 'bookmark' | 'unbookmark' | 'mark_as_read' | 'mark_as_unread';
1619

1720
interface SelectionFABProps{
18-
selectedChapters: IChapter[]
19-
onAction: (action: 'download') => void
21+
selectedChapters: IChapterWithMeta[]
22+
onAction: (action: SelectionAction, chapters: IChapterWithMeta[]) => void
2023
}
2124

2225
const SelectionFAB: React.FC<SelectionFABProps> = (props) => {
@@ -27,6 +30,11 @@ const SelectionFAB: React.FC<SelectionFABProps> = (props) => {
2730
const [open, setOpen] = useState(false);
2831
const handleClose = () => setOpen(false);
2932

33+
const handleAction = (action: SelectionAction, chapters: IChapterWithMeta[]) => {
34+
onAction(action, chapters);
35+
handleClose();
36+
};
37+
3038
return (
3139
<Box
3240
sx={{
@@ -54,24 +62,44 @@ const SelectionFAB: React.FC<SelectionFABProps> = (props) => {
5462
'aria-labelledby': 'selectionMenuButton',
5563
}}
5664
>
57-
<MenuItem
58-
onClick={() => { onAction('download'); handleClose(); }}
59-
>
60-
<ListItemIcon>
61-
<Download fontSize="small" />
62-
</ListItemIcon>
63-
<ListItemText>
64-
Download selected
65-
</ListItemText>
66-
</MenuItem>
67-
{/* <MenuItem onClick={() => { onClearSelection(); handleClose(); }}>
68-
<ListItemIcon>
69-
<Clear fontSize="small" />
70-
</ListItemIcon>
71-
<ListItemText>
72-
ClearSelection
73-
</ListItemText>
74-
</MenuItem> */}
65+
<SelectionFABActionItem
66+
action="download"
67+
matchingChapters={selectedChapters.filter(
68+
({ chapter: c, downloadChapter: dc }) => !c.downloaded && dc === undefined,
69+
)}
70+
onClick={handleAction}
71+
title="Download selected"
72+
/>
73+
<SelectionFABActionItem
74+
action="delete"
75+
matchingChapters={selectedChapters.filter(({ chapter }) => chapter.downloaded)}
76+
onClick={handleAction}
77+
title="Delete selected"
78+
/>
79+
<SelectionFABActionItem
80+
action="bookmark"
81+
matchingChapters={selectedChapters.filter(({ chapter }) => !chapter.bookmarked)}
82+
onClick={handleAction}
83+
title="Bookmark selected"
84+
/>
85+
<SelectionFABActionItem
86+
action="unbookmark"
87+
matchingChapters={selectedChapters.filter(({ chapter }) => chapter.bookmarked)}
88+
onClick={handleAction}
89+
title="Remove bookmarks from selected"
90+
/>
91+
<SelectionFABActionItem
92+
action="mark_as_read"
93+
matchingChapters={selectedChapters.filter(({ chapter }) => !chapter.read)}
94+
onClick={handleAction}
95+
title="Mark selected as read"
96+
/>
97+
<SelectionFABActionItem
98+
action="mark_as_unread"
99+
matchingChapters={selectedChapters.filter(({ chapter }) => chapter.read)}
100+
onClick={handleAction}
101+
title="Mark selected as unread"
102+
/>
75103
</Menu>
76104
</Box>
77105
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright (C) Contributors to the Suwayomi project
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
7+
8+
import BookmarkAdd from '@mui/icons-material/BookmarkAdd';
9+
import BookmarkRemove from '@mui/icons-material/BookmarkRemove';
10+
import Delete from '@mui/icons-material/Delete';
11+
import Done from '@mui/icons-material/Done';
12+
import Download from '@mui/icons-material/Download';
13+
import RemoveDone from '@mui/icons-material/RemoveDone';
14+
import { ListItemIcon, ListItemText, MenuItem } from '@mui/material';
15+
import React from 'react';
16+
import type { IChapterWithMeta } from './ChapterList';
17+
import type { SelectionAction } from './SelectionFAB';
18+
19+
interface IProps {
20+
action: SelectionAction
21+
matchingChapters: IChapterWithMeta[]
22+
title: string
23+
onClick: (action: SelectionAction, chapters: IChapterWithMeta[]) => void
24+
}
25+
26+
const ICONS = {
27+
download: Download,
28+
delete: Delete,
29+
bookmark: BookmarkAdd,
30+
unbookmark: BookmarkRemove,
31+
mark_as_read: Done,
32+
mark_as_unread: RemoveDone,
33+
};
34+
35+
const SelectionFABActionItem: React.FC<IProps> = ({
36+
action, matchingChapters, onClick, title,
37+
}) => {
38+
const count = matchingChapters.length;
39+
const Icon = ICONS[action];
40+
return (
41+
<MenuItem
42+
onClick={() => onClick(action, matchingChapters)}
43+
disabled={count === 0}
44+
>
45+
<ListItemIcon>
46+
<Icon fontSize="small" />
47+
</ListItemIcon>
48+
<ListItemText>
49+
{title}
50+
{count > 0 ? ` (${count})` : ''}
51+
</ListItemText>
52+
</MenuItem>
53+
);
54+
};
55+
56+
export default SelectionFABActionItem;

src/components/util/helpers.ts

+5
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,8 @@ export const pluralize = (count: number, input: string | { one: string, many: st
1212
}
1313
return input[count === 1 ? 'one' : 'many'];
1414
};
15+
16+
export const interpolate = (count: number, input: { one: string, many: string }) => {
17+
const text = count === 1 ? input.one : input.many;
18+
return text.replaceAll('%count%', count.toString());
19+
};

src/typings.d.ts

+7
Original file line numberDiff line numberDiff line change
@@ -289,3 +289,10 @@ interface LibraryOptions {
289289
sorts: NullAndUndefined<string>
290290
sortDesc: NullAndUndefined<boolean>
291291
}
292+
293+
interface BatchChaptersChange {
294+
delete?: boolean
295+
isRead?: boolean
296+
isBookmarked?: boolean
297+
lastPageRead?: number
298+
}

0 commit comments

Comments
 (0)