diff --git a/src/components/manga/ChapterList.tsx b/src/components/manga/ChapterList.tsx index f7bdaf9a..d12c4ff6 100644 --- a/src/components/manga/ChapterList.tsx +++ b/src/components/manga/ChapterList.tsx @@ -15,9 +15,10 @@ 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 { pluralize } from 'components/util/helpers'; +import { interpolate } from 'components/util/helpers'; import makeToast from 'components/util/Toast'; import React, { + ComponentProps, useEffect, useMemo, useRef, useState, } from 'react'; 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 { mangaId: string } @@ -81,11 +115,6 @@ const ChapterList: React.FC = ({ mangaId }) => { .find((c) => c.read === false), [visibleChapters]); - const selectedChapters = useMemo(() => { - if (selection === null) return null; - return visibleChapters.filter((chap) => selection.includes(chap.id)); - }, [visibleChapters, selection]); - const handleSelection = (index: number) => { const chapter = visibleChapters[index]; if (!chapter) return; @@ -110,16 +139,30 @@ const ChapterList: React.FC = ({ mangaId }) => { setSelection(null); }; - const handleFabAction = (action: 'download') => { - if (!selectedChapters || selectedChapters.length === 0) return; - const chapterIds = selectedChapters.map((c) => c.id); + const handleFabAction: ComponentProps['onAction'] = (action, actionChapters) => { + if (actionChapters.length === 0) return; + const chapterIds = actionChapters.map(({ chapter }) => chapter.id); + + let actionPromise: Promise; if (action === 'download') { - client.post('/api/v1/download/batch', { chapterIds }) - .then(() => makeToast(`${chapterIds.length} ${pluralize(chapterIds.length, 'download')} added`, 'success')) - .then(() => mutate()) - .catch(() => makeToast('Error adding downloads', 'error')); + 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 }); } + + actionPromise + .then(() => makeToast(interpolate(chapterIds.length, actionsStrings[action].success), 'success')) + .then(() => mutate()) + .catch(() => makeToast(interpolate(chapterIds.length, actionsStrings[action].error), 'error')); }; if (loading) { @@ -138,7 +181,7 @@ const ChapterList: React.FC = ({ mangaId }) => { const noChaptersFound = chapters.length === 0; const noChaptersMatchingFilter = !noChaptersFound && visibleChapters.length === 0; - const scrollCache = visibleChapters.map((chapter) => { + const chaptersWithMeta: IChapterWithMeta[] = visibleChapters.map((chapter) => { const downloadChapter = queue?.find( (cd) => cd.chapterIndex === chapter.index && cd.mangaId === chapter.mangaId, @@ -151,6 +194,10 @@ const ChapterList: React.FC = ({ mangaId }) => { }; }); + const selectedChapters = (selection === null) + ? null + : chaptersWithMeta.filter(({ chapter }) => selection.includes(chapter.id)); + return ( <> @@ -193,7 +240,7 @@ const ChapterList: React.FC = ({ mangaId }) => { itemContent={(index:number) => ( mutate()} onSelect={() => handleSelection(index)} diff --git a/src/components/manga/SelectionFAB.tsx b/src/components/manga/SelectionFAB.tsx index 83b6ab57..ac1b533b 100644 --- a/src/components/manga/SelectionFAB.tsx +++ b/src/components/manga/SelectionFAB.tsx @@ -5,18 +5,21 @@ * 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 Download from '@mui/icons-material/Download'; import MoreHoriz from '@mui/icons-material/MoreHoriz'; import { - Fab, ListItemIcon, ListItemText, Menu, MenuItem, + Fab, Menu, } from '@mui/material'; import { Box } from '@mui/system'; import { pluralize } from 'components/util/helpers'; 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{ - selectedChapters: IChapter[] - onAction: (action: 'download') => void + selectedChapters: IChapterWithMeta[] + onAction: (action: SelectionAction, chapters: IChapterWithMeta[]) => void } const SelectionFAB: React.FC = (props) => { @@ -27,6 +30,11 @@ const SelectionFAB: React.FC = (props) => { const [open, setOpen] = useState(false); const handleClose = () => setOpen(false); + const handleAction = (action: SelectionAction, chapters: IChapterWithMeta[]) => { + onAction(action, chapters); + handleClose(); + }; + return ( = (props) => { 'aria-labelledby': 'selectionMenuButton', }} > - { onAction('download'); handleClose(); }} - > - - - - - Download selected - - - {/* { onClearSelection(); handleClose(); }}> - - - - - ClearSelection - - */} + !c.downloaded && dc === undefined, + )} + onClick={handleAction} + title="Download selected" + /> + chapter.downloaded)} + onClick={handleAction} + title="Delete selected" + /> + !chapter.bookmarked)} + onClick={handleAction} + title="Bookmark selected" + /> + chapter.bookmarked)} + onClick={handleAction} + title="Remove bookmarks from selected" + /> + !chapter.read)} + onClick={handleAction} + title="Mark selected as read" + /> + chapter.read)} + onClick={handleAction} + title="Mark selected as unread" + /> ); diff --git a/src/components/manga/SelectionFABActionItem.tsx b/src/components/manga/SelectionFABActionItem.tsx new file mode 100644 index 00000000..2b6abbc6 --- /dev/null +++ b/src/components/manga/SelectionFABActionItem.tsx @@ -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 = ({ + action, matchingChapters, onClick, title, +}) => { + const count = matchingChapters.length; + const Icon = ICONS[action]; + return ( + onClick(action, matchingChapters)} + disabled={count === 0} + > + + + + + {title} + {count > 0 ? ` (${count})` : ''} + + + ); +}; + +export default SelectionFABActionItem; diff --git a/src/components/util/helpers.ts b/src/components/util/helpers.ts index 8169d438..b0630d29 100644 --- a/src/components/util/helpers.ts +++ b/src/components/util/helpers.ts @@ -12,3 +12,8 @@ export const pluralize = (count: number, input: string | { one: string, many: st } 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()); +}; diff --git a/src/typings.d.ts b/src/typings.d.ts index 916ae6f9..cff1357d 100644 --- a/src/typings.d.ts +++ b/src/typings.d.ts @@ -289,3 +289,10 @@ interface LibraryOptions { sorts: NullAndUndefined sortDesc: NullAndUndefined } + +interface BatchChaptersChange { + delete?: boolean + isRead?: boolean + isBookmarked?: boolean + lastPageRead?: number +}