Feature/batch chapter actions (#194)
* Add batch delete chapters * Add remaining batch actions
This commit is contained in:
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
56
src/components/manga/SelectionFABActionItem.tsx
Normal file
56
src/components/manga/SelectionFABActionItem.tsx
Normal 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;
|
||||||
@@ -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
7
src/typings.d.ts
vendored
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user