Feature/cleanup chapter actions (#525)

* Extract chapter actions into "Chapters"

* Extract chapter action menu into component

* Rename "SelectionFABActionItem" to "MenuItem"

* Decouple "MenuItem" from "SelectionFAB"

* Use "MenuItem" in "ActionMenus"

* Merge chapter action menus

* Simplify chapter "mark as read" action
This commit is contained in:
schroda
2023-12-30 04:04:59 +01:00
committed by GitHub
parent d15be603b1
commit a3a52064cc
14 changed files with 799 additions and 587 deletions

View File

@@ -0,0 +1,204 @@
/*
* 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 CheckBoxOutlineBlank from '@mui/icons-material/CheckBoxOutlineBlank';
import Delete from '@mui/icons-material/Delete';
import Download from '@mui/icons-material/Download';
import RemoveDone from '@mui/icons-material/RemoveDone';
import Done from '@mui/icons-material/Done';
import { useTranslation } from 'react-i18next';
import BookmarkRemove from '@mui/icons-material/BookmarkRemove';
import BookmarkAdd from '@mui/icons-material/BookmarkAdd';
import DoneAll from '@mui/icons-material/DoneAll';
import { t as translate } from 'i18next';
import { useMemo } from 'react';
import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts';
import {
actionToTranslationKey,
ChapterAction,
ChapterBookmarkInfo,
ChapterDownloadInfo,
ChapterReadInfo,
Chapters,
} from '@/lib/data/Chapters.ts';
import { TChapter } from '@/typings.ts';
import { MenuItem } from '@/components/manga/MenuItem.tsx';
import { IChapterWithMeta } from '@/components/manga/ChapterList.tsx';
import { ChaptersWithMeta } from '@/lib/data/ChaptersWithMeta.ts';
const createGetMenuItemTitle =
(isSingleMode: boolean) =>
(action: ChapterAction, count: number): string => {
const countSuffix = count > 0 ? ` (${count})` : '';
return `${translate(
actionToTranslationKey[action].action[isSingleMode ? 'single' : 'selected'],
)}${countSuffix}`;
};
const createShouldShowMenuItem =
(isSingleMode: boolean) =>
(shouldBeVisible: boolean = false): boolean =>
isSingleMode ? shouldBeVisible : true;
const createIsMenuItemDisabled =
(isSingleMode: boolean) =>
(shouldBeDisabled: boolean): boolean =>
isSingleMode ? false : shouldBeDisabled;
type BaseProps = { onClose: () => void };
type SingleModeProps = {
chapter: ChapterDownloadInfo & ChapterBookmarkInfo & ChapterReadInfo;
allChapters: TChapter[];
handleSelection?: SelectableCollectionReturnType<TChapter['id']>['handleSelection'];
canBeDownloaded: boolean;
};
type SelectModeProps = {
selectedChapters: IChapterWithMeta[];
};
type Props =
| (BaseProps & SingleModeProps & PropertiesNever<SelectModeProps>)
| (BaseProps & PropertiesNever<SingleModeProps> & SelectModeProps);
export const ChapterActionMenuItems = ({
chapter,
allChapters,
handleSelection,
canBeDownloaded = false,
selectedChapters = [],
onClose,
}: Props) => {
const { t } = useTranslation();
const isSingleMode = !!chapter;
const { isDownloaded, isRead, isBookmarked } = chapter ?? {};
const getMenuItemTitle = createGetMenuItemTitle(isSingleMode);
const shouldShowMenuItem = createShouldShowMenuItem(isSingleMode);
const isMenuItemDisabled = createIsMenuItemDisabled(isSingleMode);
const {
downloadableChapters,
downloadedChapters,
unbookmarkedChapters,
bookmarkedChapters,
unreadChapters,
readChapters,
} = useMemo(
() => ({
downloadableChapters: ChaptersWithMeta.getDownloadable(selectedChapters),
downloadedChapters: ChaptersWithMeta.getDownloaded(selectedChapters),
unbookmarkedChapters: ChaptersWithMeta.getNonBookmarked(selectedChapters),
bookmarkedChapters: ChaptersWithMeta.getBookmarked(selectedChapters),
unreadChapters: ChaptersWithMeta.getNonRead(selectedChapters),
readChapters: ChaptersWithMeta.getRead(selectedChapters),
}),
[selectedChapters],
);
const handleSelect = () => {
handleSelection?.(chapter.id, true);
onClose();
};
const performAction = (action: ChapterAction | 'mark_prev_as_read', chaptersWithMeta: IChapterWithMeta[]) => {
const isMarkPrevAsRead = action === 'mark_prev_as_read';
const actualAction: ChapterAction = isMarkPrevAsRead ? 'mark_as_read' : action;
const getChapters = (): (ChapterDownloadInfo & ChapterBookmarkInfo & ChapterReadInfo)[] => {
// select mode
if (!chapter) {
return ChaptersWithMeta.getChapters(chaptersWithMeta);
}
if (!isMarkPrevAsRead) {
return [chapter];
}
const index = allChapters.findIndex(({ id: chapterId }) => chapterId === chapter.id);
const isFirstChapter = index + 1 > allChapters.length - 1;
if (isFirstChapter) {
return [];
}
return allChapters.slice(index + 1);
};
Chapters.performAction(actualAction, chapter ? [chapter.id] : ChaptersWithMeta.getIds(chaptersWithMeta), {
chapters: getChapters(),
wasManuallyMarkedAsRead: true,
});
onClose();
};
return (
<>
{isSingleMode && (
<MenuItem onClick={handleSelect} Icon={CheckBoxOutlineBlank} title={t('chapter.action.label.select')} />
)}
{shouldShowMenuItem(canBeDownloaded) && (
<MenuItem
Icon={Download}
isDisabled={isMenuItemDisabled(!downloadableChapters.length)}
onClick={() => performAction('download', downloadableChapters)}
title={getMenuItemTitle('download', downloadableChapters.length)}
/>
)}
{shouldShowMenuItem(isDownloaded) && (
<MenuItem
Icon={Delete}
isDisabled={isMenuItemDisabled(!downloadedChapters.length)}
onClick={() => performAction('delete', downloadedChapters)}
title={getMenuItemTitle('delete', downloadedChapters.length)}
/>
)}
{shouldShowMenuItem(!isBookmarked) && (
<MenuItem
Icon={BookmarkAdd}
isDisabled={isMenuItemDisabled(!unbookmarkedChapters.length)}
onClick={() => performAction('bookmark', unbookmarkedChapters)}
title={getMenuItemTitle('bookmark', unbookmarkedChapters.length)}
/>
)}
{shouldShowMenuItem(isBookmarked) && (
<MenuItem
Icon={BookmarkRemove}
isDisabled={isMenuItemDisabled(!bookmarkedChapters.length)}
onClick={() => performAction('unbookmark', bookmarkedChapters)}
title={getMenuItemTitle('unbookmark', bookmarkedChapters.length)}
/>
)}
{shouldShowMenuItem(!isRead) && (
<MenuItem
Icon={Done}
isDisabled={isMenuItemDisabled(!unreadChapters.length)}
onClick={() => performAction('mark_as_read', unreadChapters)}
title={getMenuItemTitle('mark_as_read', unreadChapters.length)}
/>
)}
{shouldShowMenuItem(isRead) && (
<MenuItem
Icon={RemoveDone}
isDisabled={isMenuItemDisabled(!readChapters.length)}
onClick={() => performAction('mark_as_unread', readChapters)}
title={getMenuItemTitle('mark_as_unread', readChapters.length)}
/>
)}
{isSingleMode && (
<MenuItem
onClick={() => performAction('mark_prev_as_read', [])}
Icon={DoneAll}
title={t('chapter.action.mark_as_read.add.label.action.previous')}
/>
)}
</>
);
};

View File

@@ -7,32 +7,23 @@
*/ */
import BookmarkIcon from '@mui/icons-material/Bookmark'; import BookmarkIcon from '@mui/icons-material/Bookmark';
import BookmarkAdd from '@mui/icons-material/BookmarkAdd';
import BookmarkRemove from '@mui/icons-material/BookmarkRemove';
import CheckBoxOutlineBlank from '@mui/icons-material/CheckBoxOutlineBlank';
import Delete from '@mui/icons-material/Delete';
import Done from '@mui/icons-material/Done';
import DoneAll from '@mui/icons-material/DoneAll';
import Download from '@mui/icons-material/Download';
import MoreVertIcon from '@mui/icons-material/MoreVert'; import MoreVertIcon from '@mui/icons-material/MoreVert';
import RemoveDone from '@mui/icons-material/RemoveDone'; import { CardActionArea, Checkbox, Stack, Tooltip } from '@mui/material';
import { CardActionArea, Checkbox, ListItemIcon, ListItemText, Stack, Tooltip } from '@mui/material';
import Card from '@mui/material/Card'; import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent'; import CardContent from '@mui/material/CardContent';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import React from 'react'; import React, { TouchEvent } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { requestManager } from '@/lib/requests/RequestManager.ts'; import PopupState, { bindMenu, bindTrigger } from 'material-ui-popup-state';
import { getUploadDateString } from '@/util/date'; import { getUploadDateString } from '@/util/date';
import { DownloadStateIndicator } from '@/components/molecules/DownloadStateIndicator'; import { DownloadStateIndicator } from '@/components/molecules/DownloadStateIndicator';
import { DownloadType, UpdateChapterPatchInput } from '@/lib/graphql/generated/graphql.ts'; import { DownloadType } from '@/lib/graphql/generated/graphql.ts';
import { TChapter } from '@/typings.ts'; import { TChapter } from '@/typings.ts';
import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts'; import { ChapterActionMenuItems } from '@/components/manga/ChapterActionMenuItems.tsx';
import { Menu } from '@/components/manga/Menu.tsx';
interface IProps { interface IProps {
chapter: TChapter; chapter: TChapter;
@@ -50,77 +41,6 @@ export const ChapterCard: React.FC<IProps> = (props: IProps) => {
const { chapter, allChapters, downloadChapter: dc, showChapterNumber, onSelect, selected } = props; const { chapter, allChapters, downloadChapter: dc, showChapterNumber, onSelect, selected } = props;
const isSelecting = selected !== null; const isSelecting = selected !== null;
const { settings: metadataServerSettings } = useMetadataServerSettings();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const handleMenuClick = (event: React.MouseEvent<HTMLButtonElement>) => {
// prevent parent tags from getting the event
event.stopPropagation();
event.preventDefault();
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
type UpdatePatchInput = UpdateChapterPatchInput & { markPrevRead?: boolean };
const sendChange = <Key extends keyof UpdatePatchInput>(key: Key, value: UpdatePatchInput[Key]) => {
handleClose();
const shouldDeleteChapter = ({ isBookmarked, isDownloaded }: TChapter) =>
isDownloaded && (!isBookmarked || metadataServerSettings.deleteChaptersWithBookmark);
const isMarkAsRead = (key === 'isRead' && value) || key === 'markPrevRead';
const shouldAutoDeleteChapters = isMarkAsRead && metadataServerSettings.deleteChaptersManuallyMarkedRead;
const chaptersToDelete = key === 'isRead' ? [chapter] : allChapters;
const chapterIdsToDelete = shouldAutoDeleteChapters
? chaptersToDelete.filter(shouldDeleteChapter).map(({ id: chapterId }) => chapterId)
: [];
if (key === 'markPrevRead') {
const index = allChapters.findIndex(({ id: chapterId }) => chapterId === chapter.id);
const isFirstChapter = index + 1 > allChapters.length - 1;
if (isFirstChapter) {
return;
}
requestManager.updateChapters(
allChapters
.slice(index + 1)
.filter(({ isRead }) => !isRead)
.map(({ id: chapterId }) => chapterId),
{ isRead: true, chapterIdsToDelete },
);
return;
}
requestManager.updateChapter(chapter.id, {
[key]: value,
lastPageRead: key === 'isRead' ? 0 : undefined,
chapterIdToDelete: chapterIdsToDelete[0],
});
};
const downloadChapter = () => {
requestManager.addChapterToDownloadQueue(chapter.id);
handleClose();
};
const deleteChapter = () => {
requestManager.deleteDownloadedChapter(chapter.id);
handleClose();
};
const handleSelect = () => {
onSelect(true);
handleClose();
};
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => { const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (isSelecting) { if (isSelecting) {
event.preventDefault(); event.preventDefault();
@@ -130,10 +50,30 @@ export const ChapterCard: React.FC<IProps> = (props: IProps) => {
}; };
const { isDownloaded } = chapter; const { isDownloaded } = chapter;
const canBeDownloaded = !chapter.isDownloaded && dc === undefined;
return ( return (
<li> <li>
<PopupState variant="popover" popupId="chapter-card-action-menu">
{(popupState) => {
const bindTriggerProps = bindTrigger(popupState);
const preventDefaultAction = (e: React.BaseSyntheticEvent<unknown>) => {
e.stopPropagation();
e.preventDefault();
};
const handleClickOpenMenu = (e: React.BaseSyntheticEvent<unknown>) => {
preventDefaultAction(e);
bindTriggerProps.onClick(e as any);
};
const handleTouchStart = (e: React.BaseSyntheticEvent<unknown>) => {
preventDefaultAction(e);
bindTriggerProps.onTouchStart(e as TouchEvent);
};
return (
<>
<Card <Card
sx={{ sx={{
position: 'relative', position: 'relative',
@@ -165,7 +105,9 @@ export const ChapterCard: React.FC<IProps> = (props: IProps) => {
sx={{ mr: 0.5, position: 'relative', top: '0.15em' }} sx={{ mr: 0.5, position: 'relative', top: '0.15em' }}
/> />
)} )}
{showChapterNumber ? `${t('chapter.title')} ${chapter.chapterNumber}` : chapter.name} {showChapterNumber
? `${t('chapter.title')} ${chapter.chapterNumber}`
: chapter.name}
</Typography> </Typography>
<Typography variant="caption">{chapter.scanlator}</Typography> <Typography variant="caption">{chapter.scanlator}</Typography>
<Typography variant="caption"> <Typography variant="caption">
@@ -178,68 +120,43 @@ export const ChapterCard: React.FC<IProps> = (props: IProps) => {
{selected === null ? ( {selected === null ? (
<Tooltip title={t('global.button.options')}> <Tooltip title={t('global.button.options')}>
<IconButton aria-label="more" onClick={handleMenuClick} size="large"> <IconButton
{...bindTriggerProps}
onClick={handleClickOpenMenu}
onTouchStart={handleTouchStart}
aria-label="more"
size="large"
>
<MoreVertIcon /> <MoreVertIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
) : ( ) : (
<Tooltip title={t(selected ? 'global.button.deselect' : 'global.button.select')}> <Tooltip
title={t(selected ? 'global.button.deselect' : 'global.button.select')}
>
<Checkbox checked={selected} /> <Checkbox checked={selected} />
</Tooltip> </Tooltip>
)} )}
</CardContent> </CardContent>
</CardActionArea> </CardActionArea>
<Menu anchorEl={anchorEl} keepMounted open={Boolean(anchorEl)} onClose={handleClose}>
<MenuItem onClick={handleSelect}>
<ListItemIcon>
<CheckBoxOutlineBlank fontSize="small" />
</ListItemIcon>
<ListItemText>{t('chapter.action.label.select')}</ListItemText>
</MenuItem>
{isDownloaded && (
<MenuItem onClick={deleteChapter}>
<ListItemIcon>
<Delete fontSize="small" />
</ListItemIcon>
<ListItemText>{t('chapter.action.download.delete.label.action')}</ListItemText>
</MenuItem>
)}
{canBeDownloaded && (
<MenuItem onClick={downloadChapter}>
<ListItemIcon>
<Download fontSize="small" />
</ListItemIcon>
<ListItemText>{t('chapter.action.download.add.label.action')}</ListItemText>
</MenuItem>
)}
<MenuItem onClick={() => sendChange('isBookmarked', !chapter.isBookmarked)}>
<ListItemIcon>
{chapter.isBookmarked && <BookmarkRemove fontSize="small" />}
{!chapter.isBookmarked && <BookmarkAdd fontSize="small" />}
</ListItemIcon>
<ListItemText>
{chapter.isBookmarked && t('chapter.action.bookmark.remove.label.action')}
{!chapter.isBookmarked && t('chapter.action.bookmark.add.label.action')}
</ListItemText>
</MenuItem>
<MenuItem onClick={() => sendChange('isRead', !chapter.isRead)}>
<ListItemIcon>
{chapter.isRead && <RemoveDone fontSize="small" />}
{!chapter.isRead && <Done fontSize="small" />}
</ListItemIcon>
<ListItemText>
{chapter.isRead && t('chapter.action.mark_as_read.remove.label.action')}
{!chapter.isRead && t('chapter.action.mark_as_read.add.label.action.current')}
</ListItemText>
</MenuItem>
<MenuItem onClick={() => sendChange('markPrevRead', true)}>
<ListItemIcon>
<DoneAll fontSize="small" />
</ListItemIcon>
<ListItemText>{t('chapter.action.mark_as_read.add.label.action.previous')}</ListItemText>
</MenuItem>
</Menu>
</Card> </Card>
{!isSelecting && popupState.isOpen && (
<Menu {...bindMenu(popupState)}>
{(onClose) => (
<ChapterActionMenuItems
onClose={onClose}
chapter={chapter}
allChapters={allChapters}
handleSelection={() => onSelect(true)}
canBeDownloaded={!chapter.isDownloaded && !dc}
/>
)}
</Menu>
)}
</>
);
}}
</PopupState>
</li> </li>
); );
}; };

View File

@@ -8,27 +8,27 @@
import { Box, CircularProgress, Stack, styled, Tooltip } from '@mui/material'; import { Box, CircularProgress, Stack, styled, Tooltip } from '@mui/material';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import React, { ComponentProps, useMemo } from 'react'; import React, { useMemo } from 'react';
import { Virtuoso } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import DownloadIcon from '@mui/icons-material/Download'; import DownloadIcon from '@mui/icons-material/Download';
import DoneAllIcon from '@mui/icons-material/DoneAll'; import DoneAllIcon from '@mui/icons-material/DoneAll';
import { TChapter, TManga, TranslationKey } from '@/typings'; import { TChapter, TManga } from '@/typings';
import { requestManager } from '@/lib/requests/RequestManager.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts';
import { ChapterCard } from '@/components/manga/ChapterCard'; 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 { makeToast } from '@/components/util/Toast';
import { ChaptersToolbarMenu } from '@/components/manga/ChaptersToolbarMenu'; import { ChaptersToolbarMenu } from '@/components/manga/ChaptersToolbarMenu';
import { SelectionFAB } from '@/components/manga/SelectionFAB'; import { SelectionFAB } from '@/components/manga/SelectionFAB';
import { DEFAULT_FULL_FAB_HEIGHT } from '@/components/util/StyledFab'; import { DEFAULT_FULL_FAB_HEIGHT } from '@/components/util/StyledFab';
import { DownloadType, UpdateChapterPatchInput } from '@/lib/graphql/generated/graphql.ts'; import { DownloadType } from '@/lib/graphql/generated/graphql.ts';
import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
import { useSelectableCollection } from '@/components/collection/useSelectableCollection.ts'; import { useSelectableCollection } from '@/components/collection/useSelectableCollection.ts';
import { SelectableCollectionSelectAll } from '@/components/collection/SelectableCollectionSelectAll.tsx'; import { SelectableCollectionSelectAll } from '@/components/collection/SelectableCollectionSelectAll.tsx';
import { ChapterSelectionFABActionItems } from '@/components/manga/ChapterSelectionFABActionItems.tsx'; import { Chapters } from '@/lib/data/Chapters.ts';
import { ChaptersWithMeta } from '@/lib/data/ChaptersWithMeta.ts';
import { ChapterActionMenuItems } from '@/components/manga/ChapterActionMenuItems.tsx';
const ChapterListHeader = styled(Stack)(({ theme }) => ({ const ChapterListHeader = styled(Stack)(({ theme }) => ({
margin: 8, margin: 8,
@@ -52,38 +52,6 @@ const StyledVirtuoso = styled(Virtuoso)(({ theme }) => ({
}, },
})); }));
const actionsStrings: {
[key in 'download' | 'delete' | 'bookmark' | 'unbookmark' | 'mark_as_read' | 'mark_as_unread']: {
success: TranslationKey;
error: TranslationKey;
};
} = {
download: {
success: 'chapter.action.download.add.label.success',
error: 'chapter.action.download.add.label.error',
},
delete: {
success: 'chapter.action.download.delete.label.success',
error: 'chapter.action.download.delete.label.error',
},
bookmark: {
success: 'chapter.action.bookmark.add.label.success',
error: 'chapter.action.bookmark.add.label.error',
},
unbookmark: {
success: 'chapter.action.bookmark.remove.label.success',
error: 'chapter.action.bookmark.remove.label.error',
},
mark_as_read: {
success: 'chapter.action.mark_as_read.add.label.success',
error: 'chapter.action.mark_as_read.add.label.error',
},
mark_as_unread: {
success: 'chapter.action.mark_as_read.remove.label.success',
error: 'chapter.action.mark_as_read.remove.label.error',
},
};
export interface IChapterWithMeta { export interface IChapterWithMeta {
chapter: TChapter; chapter: TChapter;
downloadChapter: DownloadType | undefined; downloadChapter: DownloadType | undefined;
@@ -108,8 +76,6 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
const { areNoItemsSelected, areAllItemsSelected, selectedItemIds, handleSelectAll, handleSelection } = const { areNoItemsSelected, areAllItemsSelected, selectedItemIds, handleSelectAll, handleSelection } =
useSelectableCollection(chapters.length, { currentKey: 'default' }); useSelectableCollection(chapters.length, { currentKey: 'default' });
const { settings: metadataServerSettings } = useMetadataServerSettings();
const visibleChapters = useMemo(() => filterAndSortChapters(chapters, options), [chapters, options]); const visibleChapters = useMemo(() => filterAndSortChapters(chapters, options), [chapters, options]);
const nextChapterIndexToRead = (manga.lastReadChapter?.sourceOrder ?? 0) + 1; const nextChapterIndexToRead = (manga.lastReadChapter?.sourceOrder ?? 0) + 1;
@@ -118,70 +84,6 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
const areAllChaptersRead = manga.unreadCount === 0; const areAllChaptersRead = manga.unreadCount === 0;
const areAllChaptersDownloaded = manga.downloadCount === manga.chapters.totalCount; const areAllChaptersDownloaded = manga.downloadCount === manga.chapters.totalCount;
const handleFabAction: ComponentProps<typeof ChapterSelectionFABActionItems>['onAction'] = (
action,
actionChapters,
) => {
if (actionChapters.length === 0) return;
const chapterIds = actionChapters
.filter(({ chapter }) => {
switch (action) {
case 'download':
return !chapter.isDownloaded;
case 'delete':
return chapter.isDownloaded;
case 'bookmark':
return !chapter.isBookmarked;
case 'unbookmark':
return chapter.isBookmarked;
case 'mark_as_read':
return !chapter.isRead;
case 'mark_as_unread':
return chapter.isRead;
default:
throw new Error(`ChapterList::handleFabAction: unknown action "${action}"`);
}
})
.map(({ chapter }) => chapter.id);
let actionPromise: Promise<any>;
if (action === 'download') {
actionPromise = requestManager.addChaptersToDownloadQueue(chapterIds).response;
} else {
const change: UpdateChapterPatchInput = {};
if (action === 'bookmark') change.isBookmarked = true;
else if (action === 'unbookmark') change.isBookmarked = false;
else if (action === 'mark_as_read' || action === 'mark_as_unread') {
change.isRead = action === 'mark_as_read';
change.lastPageRead = 0;
}
if (action === 'delete') {
actionPromise = requestManager.deleteDownloadedChapters(chapterIds).response;
} else {
const shouldDeleteChapters =
action === 'mark_as_read' && metadataServerSettings.deleteChaptersManuallyMarkedRead;
const chapterIdsToDelete = shouldDeleteChapters
? actionChapters
.filter(
({ chapter }) =>
chapter.isDownloaded &&
(!chapter.isBookmarked || metadataServerSettings.deleteChaptersWithBookmark),
)
.map(({ chapter }) => chapter.id)
: [];
actionPromise = requestManager.updateChapters(chapterIds, { ...change, chapterIdsToDelete }).response;
}
}
actionPromise
.then(() => makeToast(t(actionsStrings[action].success, { count: chapterIds.length }), 'success'))
.catch(() => makeToast(t(actionsStrings[action].error, { count: chapterIds.length }), 'error'));
};
const noChaptersFound = chapters.length === 0; const noChaptersFound = chapters.length === 0;
const noChaptersMatchingFilter = !noChaptersFound && visibleChapters.length === 0; const noChaptersMatchingFilter = !noChaptersFound && visibleChapters.length === 0;
@@ -208,11 +110,7 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
return ( return (
<SelectionFAB selectedItemsCount={selectedChapters.length} title="chapter.title"> <SelectionFAB selectedItemsCount={selectedChapters.length} title="chapter.title">
{(handleClose) => ( {(handleClose) => (
<ChapterSelectionFABActionItems <ChapterActionMenuItems selectedChapters={selectedChapters} onClose={handleClose} />
selectedChapters={selectedChapters}
onAction={handleFabAction}
handleClose={handleClose}
/>
)} )}
</SelectionFAB> </SelectionFAB>
); );
@@ -253,7 +151,12 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
<Tooltip title={t('chapter.action.mark_as_read.add.label.action.current')}> <Tooltip title={t('chapter.action.mark_as_read.add.label.action.current')}>
<IconButton <IconButton
disabled={areAllChaptersRead} disabled={areAllChaptersRead}
onClick={() => handleFabAction('mark_as_read', chaptersWithMeta)} onClick={() =>
Chapters.markAsRead(
ChaptersWithMeta.getChapters(ChaptersWithMeta.getNonRead(chaptersWithMeta)),
true,
)
}
> >
<DoneAllIcon /> <DoneAllIcon />
</IconButton> </IconButton>
@@ -261,7 +164,11 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
<Tooltip title={t('chapter.action.download.add.label.action')}> <Tooltip title={t('chapter.action.download.add.label.action')}>
<IconButton <IconButton
disabled={areAllChaptersDownloaded} disabled={areAllChaptersDownloaded}
onClick={() => handleFabAction('download', chaptersWithMeta)} onClick={() =>
Chapters.download(
ChaptersWithMeta.getIds(ChaptersWithMeta.getNonDownloaded(chaptersWithMeta)),
)
}
> >
<DownloadIcon /> <DownloadIcon />
</IconButton> </IconButton>

View File

@@ -1,85 +0,0 @@
/*
* 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 Download from '@mui/icons-material/Download';
import Delete from '@mui/icons-material/Delete';
import BookmarkAdd from '@mui/icons-material/BookmarkAdd';
import BookmarkRemove from '@mui/icons-material/BookmarkRemove';
import Done from '@mui/icons-material/Done';
import RemoveDone from '@mui/icons-material/RemoveDone';
import { useTranslation } from 'react-i18next';
import { SelectionFABActionItem } from '@/components/manga/SelectionFABActionItem.tsx';
import { IChapterWithMeta } from '@/components/manga/ChapterList.tsx';
type SelectionAction = 'download' | 'delete' | 'bookmark' | 'unbookmark' | 'mark_as_read' | 'mark_as_unread';
export const ChapterSelectionFABActionItems = ({
selectedChapters,
onAction,
handleClose,
}: {
selectedChapters: IChapterWithMeta[];
onAction: (action: SelectionAction, chapters: IChapterWithMeta[]) => void;
handleClose: () => void;
}) => {
const { t } = useTranslation();
const handleAction = (action: SelectionAction, chapters: IChapterWithMeta[]) => {
onAction(action, chapters);
handleClose();
};
return (
<>
<SelectionFABActionItem
action="download"
Icon={Download}
matchingItems={selectedChapters.filter(
({ chapter: c, downloadChapter: dc }) => !c.isDownloaded && dc === undefined,
)}
onClick={handleAction}
title={t('chapter.action.download.add.button.selected')}
/>
<SelectionFABActionItem
action="delete"
Icon={Delete}
matchingItems={selectedChapters.filter(({ chapter }) => chapter.isDownloaded)}
onClick={handleAction}
title={t('chapter.action.download.delete.button.selected')}
/>
<SelectionFABActionItem
action="bookmark"
Icon={BookmarkAdd}
matchingItems={selectedChapters.filter(({ chapter }) => !chapter.isBookmarked)}
onClick={handleAction}
title={t('chapter.action.bookmark.add.button.selected')}
/>
<SelectionFABActionItem
action="unbookmark"
Icon={BookmarkRemove}
matchingItems={selectedChapters.filter(({ chapter }) => chapter.isBookmarked)}
onClick={handleAction}
title={t('chapter.action.bookmark.remove.button.selected')}
/>
<SelectionFABActionItem
action="mark_as_read"
Icon={Done}
matchingItems={selectedChapters.filter(({ chapter }) => !chapter.isRead)}
onClick={handleAction}
title={t('chapter.action.mark_as_read.add.button.selected')}
/>
<SelectionFABActionItem
action="mark_as_unread"
Icon={RemoveDone}
matchingItems={selectedChapters.filter(({ chapter }) => chapter.isRead)}
onClick={handleAction}
title={t('chapter.action.mark_as_read.remove.button.selected')}
/>
</>
);
};

View File

@@ -7,8 +7,6 @@
*/ */
import Menu from '@mui/material/Menu'; import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { ListItemIcon, ListItemText } from '@mui/material';
import CheckBoxOutlineBlank from '@mui/icons-material/CheckBoxOutlineBlank'; import CheckBoxOutlineBlank from '@mui/icons-material/CheckBoxOutlineBlank';
import Delete from '@mui/icons-material/Delete'; import Delete from '@mui/icons-material/Delete';
import Download from '@mui/icons-material/Download'; import Download from '@mui/icons-material/Download';
@@ -22,8 +20,8 @@ import { useState } from 'react';
import { TManga } from '@/typings.ts'; import { TManga } from '@/typings.ts';
import { MangaAction, MangaDownloadInfo, Mangas, MangaUnreadInfo } from '@/lib/data/Mangas.ts'; import { MangaAction, MangaDownloadInfo, Mangas, MangaUnreadInfo } from '@/lib/data/Mangas.ts';
import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts'; import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts';
import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
import { CategorySelect } from '@/components/navbar/action/CategorySelect.tsx'; import { CategorySelect } from '@/components/navbar/action/CategorySelect.tsx';
import { MenuItem } from '@/components/manga/MenuItem.tsx';
export const MangaActionMenu = ({ export const MangaActionMenu = ({
manga, manga,
@@ -35,7 +33,6 @@ export const MangaActionMenu = ({
} & ReturnType<typeof bindMenu>) => { } & ReturnType<typeof bindMenu>) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { settings } = useMetadataServerSettings();
const [isCategorySelectOpen, setIsCategorySelectOpen] = useState(false); const [isCategorySelectOpen, setIsCategorySelectOpen] = useState(false);
const isFullyDownloaded = manga.downloadCount === manga.chapters.totalCount; const isFullyDownloaded = manga.downloadCount === manga.chapters.totalCount;
@@ -50,7 +47,7 @@ export const MangaActionMenu = ({
const performAction = (action: MangaAction) => { const performAction = (action: MangaAction) => {
Mangas.performAction(action, [manga.id], { Mangas.performAction(action, [manga.id], {
autoDeleteChapters: settings.deleteChaptersManuallyMarkedRead, wasManuallyMarkedAsRead: true,
}).catch(() => {}); }).catch(() => {});
bindMenuProps.onClose(); bindMenuProps.onClose();
@@ -60,57 +57,50 @@ export const MangaActionMenu = ({
<> <>
<Menu {...bindMenuProps} open={bindMenuProps.open && !isCategorySelectOpen}> <Menu {...bindMenuProps} open={bindMenuProps.open && !isCategorySelectOpen}>
{!!handleSelection && ( {!!handleSelection && (
<MenuItem onClick={handleSelect}> <MenuItem
<ListItemIcon> onClick={handleSelect}
<CheckBoxOutlineBlank fontSize="small" /> Icon={CheckBoxOutlineBlank}
</ListItemIcon> title={t('chapter.action.label.select')}
<ListItemText>{t('chapter.action.label.select')}</ListItemText> />
</MenuItem>
)} )}
{!isFullyDownloaded && ( {!isFullyDownloaded && (
<MenuItem onClick={() => performAction('download')}> <MenuItem
<ListItemIcon> onClick={() => performAction('download')}
<Download fontSize="small" /> Icon={Download}
</ListItemIcon> title={t('chapter.action.download.add.label.action')}
<ListItemText>{t('chapter.action.download.add.label.action')}</ListItemText> />
</MenuItem>
)} )}
{hasDownloadedChapters && ( {hasDownloadedChapters && (
<MenuItem onClick={() => performAction('delete')}> <MenuItem
<ListItemIcon> onClick={() => performAction('delete')}
<Delete fontSize="small" /> Icon={Delete}
</ListItemIcon> title={t('chapter.action.download.delete.label.action')}
<ListItemText>{t('chapter.action.download.delete.label.action')}</ListItemText> />
</MenuItem>
)} )}
{hasUnreadChapters && ( {hasUnreadChapters && (
<MenuItem onClick={() => performAction('mark_as_read')}> <MenuItem
<ListItemIcon> onClick={() => performAction('mark_as_read')}
<Done fontSize="small" /> Icon={Done}
</ListItemIcon> title={t('chapter.action.mark_as_read.add.label.action.current')}
<ListItemText>{t('chapter.action.mark_as_read.add.label.action.current')}</ListItemText> />
</MenuItem>
)} )}
{hasReadChapters && ( {hasReadChapters && (
<MenuItem onClick={() => performAction('mark_as_unread')}> <MenuItem
<ListItemIcon> onClick={() => performAction('mark_as_unread')}
<RemoveDone fontSize="small" /> Icon={RemoveDone}
</ListItemIcon> title={t('chapter.action.mark_as_read.remove.label.action')}
<ListItemText>{t('chapter.action.mark_as_read.remove.label.action')}</ListItemText> />
</MenuItem>
)} )}
<MenuItem onClick={() => setIsCategorySelectOpen(true)}> <MenuItem
<ListItemIcon> onClick={() => setIsCategorySelectOpen(true)}
<Label fontSize="small" /> Icon={Label}
</ListItemIcon> title={t('manga.action.category.label.action')}
<ListItemText>{t('manga.action.category.label.action')}</ListItemText> />
</MenuItem> <MenuItem
<MenuItem onClick={() => performAction('remove_from_library')}> onClick={() => performAction('remove_from_library')}
<ListItemIcon> Icon={FavoriteBorderIcon}
<FavoriteBorderIcon fontSize="small" /> title={t('manga.action.library.remove.label.action')}
</ListItemIcon> />
<ListItemText>{t('manga.action.library.remove.label.action')}</ListItemText>
</MenuItem>
</Menu> </Menu>
{isCategorySelectOpen && ( {isCategorySelectOpen && (
<CategorySelect <CategorySelect

View File

@@ -10,18 +10,22 @@ import Download from '@mui/icons-material/Download';
import Delete from '@mui/icons-material/Delete'; import Delete from '@mui/icons-material/Delete';
import Done from '@mui/icons-material/Done'; import Done from '@mui/icons-material/Done';
import RemoveDone from '@mui/icons-material/RemoveDone'; import RemoveDone from '@mui/icons-material/RemoveDone';
import { useTranslation } from 'react-i18next';
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder'; import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
import Label from '@mui/icons-material/Label'; import Label from '@mui/icons-material/Label';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { SelectionFABActionItem } from '@/components/manga/SelectionFABActionItem.tsx'; import { t as translate } from 'i18next';
import { MenuItem } from '@/components/manga/MenuItem.tsx';
import { TManga } from '@/typings.ts'; import { TManga } from '@/typings.ts';
import { MangaAction, Mangas } from '@/lib/data/Mangas.ts'; import { actionToTranslationKey, MangaAction, Mangas } from '@/lib/data/Mangas.ts';
import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
import { CategorySelect } from '@/components/navbar/action/CategorySelect.tsx'; import { CategorySelect } from '@/components/navbar/action/CategorySelect.tsx';
const ACTION_DISABLES_SELECTION_MODE: MangaAction[] = ['remove_from_library'] as const; const ACTION_DISABLES_SELECTION_MODE: MangaAction[] = ['remove_from_library'] as const;
const getMenuItemTitle = (action: MangaAction, count: number): string => {
const countSuffix = count > 0 ? ` (${count})` : '';
return `${translate(actionToTranslationKey[action].action.selected)}${countSuffix}`;
};
export const MangasSelectionFABActionItems = ({ export const MangasSelectionFABActionItems = ({
selectedMangas, selectedMangas,
handleClose, handleClose,
@@ -29,66 +33,66 @@ export const MangasSelectionFABActionItems = ({
selectedMangas: TManga[]; selectedMangas: TManga[];
handleClose: (selectionModeState: boolean) => void; handleClose: (selectionModeState: boolean) => void;
}) => { }) => {
const { t } = useTranslation();
const { settings } = useMetadataServerSettings();
const [isCategorySelectOpen, setIsCategorySelectOpen] = useState(false); const [isCategorySelectOpen, setIsCategorySelectOpen] = useState(false);
const handleAction = (action: MangaAction, mangas: TManga[]) => { const handleAction = (action: MangaAction, mangas: TManga[]) => {
Mangas.performAction(action, Mangas.getIds(mangas), { Mangas.performAction(action, Mangas.getIds(mangas), {
autoDeleteChapters: settings.deleteChaptersManuallyMarkedRead, wasManuallyMarkedAsRead: true,
}).catch(() => {}); }).catch(() => {});
handleClose(!ACTION_DISABLES_SELECTION_MODE.includes(action)); handleClose(!ACTION_DISABLES_SELECTION_MODE.includes(action));
}; };
return ( const { downloadableMangas, downloadedMangas, unreadMangas, readMangas } = useMemo(
<> () => ({
<SelectionFABActionItem<MangaAction, TManga> downloadableMangas: [
action="download"
Icon={Download}
matchingItems={[
...Mangas.getNotDownloaded(selectedMangas), ...Mangas.getNotDownloaded(selectedMangas),
...Mangas.getPartiallyDownloaded(selectedMangas), ...Mangas.getPartiallyDownloaded(selectedMangas),
]} ],
onClick={handleAction} downloadedMangas: [
title={t('chapter.action.download.add.button.selected')}
/>
<SelectionFABActionItem<MangaAction, TManga>
action="delete"
Icon={Delete}
matchingItems={[
...Mangas.getPartiallyDownloaded(selectedMangas), ...Mangas.getPartiallyDownloaded(selectedMangas),
...Mangas.getFullyDownloaded(selectedMangas), ...Mangas.getFullyDownloaded(selectedMangas),
]} ],
onClick={handleAction} unreadMangas: [...Mangas.getUnread(selectedMangas), ...Mangas.getPartiallyRead(selectedMangas)],
title={t('chapter.action.download.delete.button.selected')} readMangas: [...Mangas.getPartiallyRead(selectedMangas), ...Mangas.getFullyRead(selectedMangas)],
}),
[selectedMangas],
);
return (
<>
<MenuItem
Icon={Download}
isDisabled={!downloadableMangas.length}
onClick={() => handleAction('download', downloadableMangas)}
title={getMenuItemTitle('download', downloadableMangas.length)}
/> />
<SelectionFABActionItem<MangaAction, TManga> <MenuItem
action="mark_as_read" Icon={Delete}
isDisabled={!downloadedMangas.length}
onClick={() => handleAction('delete', downloadedMangas)}
title={getMenuItemTitle('delete', downloadedMangas.length)}
/>
<MenuItem
Icon={Done} Icon={Done}
matchingItems={[...Mangas.getUnread(selectedMangas), ...Mangas.getPartiallyRead(selectedMangas)]} isDisabled={!unreadMangas.length}
onClick={handleAction} onClick={() => handleAction('mark_as_read', unreadMangas)}
title={t('chapter.action.mark_as_read.add.button.selected')} title={getMenuItemTitle('mark_as_read', unreadMangas.length)}
/> />
<SelectionFABActionItem<MangaAction, TManga> <MenuItem
action="mark_as_unread"
Icon={RemoveDone} Icon={RemoveDone}
matchingItems={[...Mangas.getPartiallyRead(selectedMangas), ...Mangas.getFullyRead(selectedMangas)]} isDisabled={!readMangas.length}
onClick={handleAction} onClick={() => handleAction('mark_as_unread', readMangas)}
title={t('chapter.action.mark_as_read.remove.button.selected')} title={getMenuItemTitle('mark_as_unread', readMangas.length)}
/> />
<SelectionFABActionItem<MangaAction, TManga> <MenuItem
action="change_categories"
Icon={Label} Icon={Label}
matchingItems={selectedMangas} onClick={() => handleAction('change_categories', selectedMangas)}
onClick={() => setIsCategorySelectOpen(true)} title={getMenuItemTitle('change_categories', selectedMangas.length)}
title={t('manga.action.category.label.action')}
/> />
<SelectionFABActionItem<MangaAction, TManga> <MenuItem
action="remove_from_library"
Icon={FavoriteBorderIcon} Icon={FavoriteBorderIcon}
matchingItems={[...Mangas.getPartiallyRead(selectedMangas), ...Mangas.getFullyRead(selectedMangas)]} onClick={() => handleAction('remove_from_library', selectedMangas)}
onClick={handleAction} title={getMenuItemTitle('remove_from_library', selectedMangas.length)}
title={t('manga.action.library.remove.button.selected')}
/> />
{isCategorySelectOpen && ( {isCategorySelectOpen && (
<CategorySelect <CategorySelect

View File

@@ -0,0 +1,21 @@
/*
* 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 { MenuProps } from '@mui/material/Menu/Menu';
import { Menu as MuiMenu } from '@mui/material';
export const Menu = ({
children,
onClose,
...props
}: Omit<MenuProps, 'children' | 'onClose'> &
Required<Pick<MenuProps, 'onClose'>> & { children: (onClose: () => void) => JSX.Element }) => (
<MuiMenu {...props} onClose={onClose}>
{children(() => onClose({}, 'backdropClick'))}
</MuiMenu>
);

View File

@@ -0,0 +1,27 @@
/*
* 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 { ListItemIcon, ListItemText, MenuItem as MuiMenuItem } from '@mui/material';
import { OverridableComponent } from '@mui/material/OverridableComponent';
import { SvgIconTypeMap } from '@mui/material/SvgIcon/SvgIcon';
interface IProps {
title: string;
Icon: OverridableComponent<SvgIconTypeMap> & { muiName: string };
onClick: () => void;
isDisabled?: boolean;
}
export const MenuItem = ({ onClick, title, Icon, isDisabled }: IProps) => (
<MuiMenuItem onClick={onClick} disabled={isDisabled}>
<ListItemIcon>
<Icon fontSize="small" />
</ListItemIcon>
<ListItemText>{title}</ListItemText>
</MuiMenuItem>
);

View File

@@ -7,14 +7,16 @@
*/ */
import MoreHoriz from '@mui/icons-material/MoreHoriz'; import MoreHoriz from '@mui/icons-material/MoreHoriz';
import { Fab, Menu, Box, styled } from '@mui/material'; import { Fab, Box, styled } from '@mui/material';
import React, { useRef, useState } from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import PopupState, { bindMenu, bindTrigger } from 'material-ui-popup-state';
import { DEFAULT_FAB_STYLE } from '@/components/util/StyledFab'; import { DEFAULT_FAB_STYLE } from '@/components/util/StyledFab';
import { TranslationKey } from '@/typings.ts'; import { TranslationKey } from '@/typings.ts';
import { Menu } from '@/components/manga/Menu.tsx';
interface SelectionFABProps { interface SelectionFABProps {
children: (handleClose: () => void) => React.ReactNode; children: (handleClose: () => void) => JSX.Element;
selectedItemsCount: number; selectedItemsCount: number;
title: TranslationKey; title: TranslationKey;
} }
@@ -32,29 +34,29 @@ const FabContainer = styled(Box)(({ theme }) => ({
export const SelectionFAB: React.FC<SelectionFABProps> = ({ children, selectedItemsCount, title }) => { export const SelectionFAB: React.FC<SelectionFABProps> = ({ children, selectedItemsCount, title }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const anchorEl = useRef<HTMLElement>();
const [open, setOpen] = useState(false);
const handleClose = () => setOpen(false);
return ( return (
<FabContainer ref={anchorEl}> <PopupState variant="popover" popupId="selection-fab-menu">
<Fab variant="extended" color="primary" id="selectionMenuButton" onClick={() => setOpen(true)}> {(popupState) => (
<>
<FabContainer {...bindTrigger(popupState)}>
<Fab variant="extended" color="primary" id="selectionMenuButton">
{`${selectedItemsCount} ${t(title, { count: selectedItemsCount })}`} {`${selectedItemsCount} ${t(title, { count: selectedItemsCount })}`}
<MoreHoriz sx={{ ml: 1 }} /> <MoreHoriz sx={{ ml: 1 }} />
</Fab> </Fab>
</FabContainer>
<Menu <Menu
{...bindMenu(popupState)}
id="selectionMenu" id="selectionMenu"
anchorEl={anchorEl.current}
open={open}
onClose={handleClose}
anchorOrigin={{ horizontal: 'right', vertical: 'top' }} anchorOrigin={{ horizontal: 'right', vertical: 'top' }}
transformOrigin={{ horizontal: 'right', vertical: 'bottom' }} transformOrigin={{ horizontal: 'right', vertical: 'bottom' }}
MenuListProps={{ MenuListProps={{
'aria-labelledby': 'selectionMenuButton', 'aria-labelledby': 'selectionMenuButton',
}} }}
> >
{children(handleClose)} {(onClose) => children(onClose)}
</Menu> </Menu>
</FabContainer> </>
)}
</PopupState>
); );
}; };

View File

@@ -1,40 +0,0 @@
/*
* 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 { ListItemIcon, ListItemText, MenuItem } from '@mui/material';
import { OverridableComponent } from '@mui/material/OverridableComponent';
import { SvgIconTypeMap } from '@mui/material/SvgIcon/SvgIcon';
interface IProps<Action, Item> {
action: Action;
matchingItems: Item[];
title: string;
Icon: OverridableComponent<SvgIconTypeMap> & { muiName: string };
onClick: (action: Action, items: Item[]) => void;
}
export const SelectionFABActionItem = <Action extends string, Item>({
action,
matchingItems,
onClick,
title,
Icon,
}: IProps<Action, Item>) => {
const count = matchingItems.length;
return (
<MenuItem onClick={() => onClick(action, matchingItems)} disabled={count === 0}>
<ListItemIcon>
<Icon fontSize="small" />
</ListItemIcon>
<ListItemText>
{title}
{count > 0 ? ` (${count})` : ''}
</ListItemText>
</MenuItem>
);
};

View File

@@ -6,25 +6,97 @@
* 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 { TChapter } from '@/typings.ts'; import { t as translate } from 'i18next';
import { TChapter, TranslationKey } from '@/typings.ts';
import { makeToast } from '@/components/util/Toast.tsx';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { getMetadataServerSettings } from '@/util/metadataServerSettings.ts';
type ChapterDownloadInfo = Pick<TChapter, 'isDownloaded'>; export type ChapterAction = 'download' | 'delete' | 'bookmark' | 'unbookmark' | 'mark_as_read' | 'mark_as_unread';
type ChapterBookmarkInfo = Pick<TChapter, 'isBookmarked'>;
export const actionToTranslationKey: {
[key in ChapterAction]: {
action: {
single: TranslationKey;
selected: TranslationKey;
};
success: TranslationKey;
error: TranslationKey;
};
} = {
download: {
action: {
single: 'chapter.action.download.add.label.action',
selected: 'chapter.action.download.add.button.selected',
},
success: 'chapter.action.download.add.label.success',
error: 'chapter.action.download.add.label.error',
},
delete: {
action: {
single: 'chapter.action.download.delete.label.action',
selected: 'chapter.action.download.delete.button.selected',
},
success: 'chapter.action.download.delete.label.success',
error: 'chapter.action.download.delete.label.error',
},
bookmark: {
action: {
single: 'chapter.action.bookmark.add.label.action',
selected: 'chapter.action.bookmark.add.button.selected',
},
success: 'chapter.action.bookmark.add.label.success',
error: 'chapter.action.bookmark.add.label.error',
},
unbookmark: {
action: {
single: 'chapter.action.bookmark.remove.label.action',
selected: 'chapter.action.bookmark.remove.button.selected',
},
success: 'chapter.action.bookmark.remove.label.success',
error: 'chapter.action.bookmark.remove.label.error',
},
mark_as_read: {
action: {
single: 'chapter.action.mark_as_read.add.label.action.current',
selected: 'chapter.action.mark_as_read.add.button.selected',
},
success: 'chapter.action.mark_as_read.add.label.success',
error: 'chapter.action.mark_as_read.add.label.error',
},
mark_as_unread: {
action: {
single: 'chapter.action.mark_as_read.remove.label.action',
selected: 'chapter.action.mark_as_read.remove.button.selected',
},
success: 'chapter.action.mark_as_read.remove.label.success',
error: 'chapter.action.mark_as_read.remove.label.error',
},
};
export type ChapterIdInfo = Pick<TChapter, 'id'>;
export type ChapterDownloadInfo = ChapterIdInfo & Pick<TChapter, 'isDownloaded'>;
export type ChapterBookmarkInfo = ChapterIdInfo & Pick<TChapter, 'isBookmarked'>;
export type ChapterReadInfo = ChapterIdInfo & Pick<TChapter, 'isRead'>;
export class Chapters { export class Chapters {
static getIds(chapters: { id: number }[]): number[] { static getIds(chapters: { id: number }[]): number[] {
return chapters.map((chapter) => chapter.id); return chapters.map((chapter) => chapter.id);
} }
static isDeletable({ isDownloaded }: ChapterDownloadInfo): boolean { static isDownloaded({ isDownloaded }: ChapterDownloadInfo): boolean {
return isDownloaded; return isDownloaded;
} }
static getDownloaded<Chapter extends ChapterDownloadInfo>(chapters: Chapter[]): Chapter[] {
return chapters.filter(Chapters.isDownloaded);
}
static isAutoDeletable( static isAutoDeletable(
{ isBookmarked, ...chapter }: ChapterDownloadInfo & ChapterBookmarkInfo, { isBookmarked, ...chapter }: ChapterDownloadInfo & ChapterBookmarkInfo,
canDeleteBookmarked: boolean = false, canDeleteBookmarked: boolean = false,
): boolean { ): boolean {
return Chapters.isDeletable(chapter) && (!isBookmarked || canDeleteBookmarked); return Chapters.isDownloaded(chapter) && (!isBookmarked || canDeleteBookmarked);
} }
static getAutoDeletable<Chapters extends ChapterDownloadInfo & ChapterBookmarkInfo>( static getAutoDeletable<Chapters extends ChapterDownloadInfo & ChapterBookmarkInfo>(
@@ -33,4 +105,139 @@ export class Chapters {
): Chapters[] { ): Chapters[] {
return chapters.filter((chapter) => Chapters.isAutoDeletable(chapter, canDeleteBookmarked)); return chapters.filter((chapter) => Chapters.isAutoDeletable(chapter, canDeleteBookmarked));
} }
static isBookmarked({ isBookmarked }: ChapterBookmarkInfo): boolean {
return isBookmarked;
}
static getBookmarked<Chapter extends ChapterBookmarkInfo>(chapters: Chapter[]): Chapter[] {
return chapters.filter(Chapters.isBookmarked);
}
static getNonBookmarked<Chapter extends ChapterBookmarkInfo>(chapters: Chapter[]): Chapter[] {
return chapters.filter((chapter) => !Chapters.isBookmarked(chapter));
}
static isRead({ isRead }: ChapterReadInfo): boolean {
return isRead;
}
static getRead<Chapter extends ChapterReadInfo>(chapters: Chapter[]): Chapter[] {
return chapters.filter(Chapters.isRead);
}
static getNonRead<Chapter extends ChapterReadInfo>(chapters: Chapter[]): Chapter[] {
return chapters.filter((chapter) => !Chapters.isRead(chapter));
}
static async download(chapterIds: number[]): Promise<void> {
return Chapters.executeAction(
'download',
chapterIds.length,
() => requestManager.addChaptersToDownloadQueue(chapterIds).response,
);
}
static async delete(chapterIds: number[]): Promise<void> {
return Chapters.executeAction(
'delete',
chapterIds.length,
() => requestManager.deleteDownloadedChapters(chapterIds).response,
);
}
static async markAsRead(
chapters: (ChapterDownloadInfo & ChapterBookmarkInfo)[],
wasManuallyMarkedAsRead: boolean = false,
): Promise<void> {
const { deleteChaptersManuallyMarkedRead, deleteChaptersWithBookmark } = await getMetadataServerSettings();
const chapterIdsToDelete =
deleteChaptersManuallyMarkedRead && wasManuallyMarkedAsRead
? Chapters.getIds(Chapters.getAutoDeletable(chapters, deleteChaptersWithBookmark))
: [];
return Chapters.executeAction(
'mark_as_read',
chapters.length,
() =>
requestManager.updateChapters(Chapters.getIds(chapters), {
isRead: true,
lastPageRead: 0,
chapterIdsToDelete,
}).response,
);
}
static async markAsUnread(chapterIds: number[]): Promise<void> {
return Chapters.executeAction(
'mark_as_unread',
chapterIds.length,
() => requestManager.updateChapters(chapterIds, { isRead: false }).response,
);
}
static async bookmark(chapterIds: number[]): Promise<void> {
return Chapters.executeAction(
'bookmark',
chapterIds.length,
() => requestManager.updateChapters(chapterIds, { isBookmarked: true }).response,
);
}
static async unBookmark(chapterIds: number[]): Promise<void> {
return Chapters.executeAction(
'unbookmark',
chapterIds.length,
() => requestManager.updateChapters(chapterIds, { isBookmarked: false }).response,
);
}
private static async executeAction(
action: ChapterAction,
itemCount: number,
fnToExecute: () => Promise<unknown>,
): Promise<void> {
try {
await fnToExecute();
makeToast(translate(actionToTranslationKey[action].success, { count: itemCount }), 'success');
} catch (e) {
makeToast(translate(actionToTranslationKey[action].error, { count: itemCount }), 'error');
throw e;
}
}
static async performAction<Action extends ChapterAction>(
action: Action,
chapterIds: number[],
{
wasManuallyMarkedAsRead,
chapters,
}: Action extends 'mark_as_read'
? { wasManuallyMarkedAsRead: boolean; chapters?: never }
: Action extends 'change_categories'
? {
wasManuallyMarkedAsRead?: never;
chapters: (ChapterDownloadInfo & ChapterBookmarkInfo & ChapterReadInfo)[];
}
: {
wasManuallyMarkedAsRead?: boolean;
chapters?: (ChapterDownloadInfo & ChapterBookmarkInfo & ChapterReadInfo)[];
},
): Promise<void> {
switch (action) {
case 'download':
return Chapters.download(chapterIds);
case 'delete':
return Chapters.delete(chapterIds);
case 'mark_as_read':
return Chapters.markAsRead(chapters!, wasManuallyMarkedAsRead!);
case 'mark_as_unread':
return Chapters.markAsUnread(chapterIds);
case 'bookmark':
return Chapters.bookmark(chapterIds);
case 'unbookmark':
return Chapters.unBookmark(chapterIds);
default:
throw new Error(`Chapters::performAction: unknown action "${action}"`);
}
}
} }

View File

@@ -0,0 +1,54 @@
/*
* 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 { TChapter } from '@/typings.ts';
import { DownloadType } from '@/lib/graphql/generated/graphql.ts';
import { Chapters } from '@/lib/data/Chapters.ts';
export type ChapterWithMetaType = {
chapter: TChapter;
downloadChapter: DownloadType | undefined;
};
export class ChaptersWithMeta {
static getChapters(chapters: ChapterWithMetaType[]): TChapter[] {
return chapters.map(({ chapter }) => chapter);
}
static getIds(chapters: ChapterWithMetaType[]): number[] {
return Chapters.getIds(ChaptersWithMeta.getChapters(chapters));
}
static getDownloaded<Chapter extends ChapterWithMetaType>(chapters: Chapter[]): Chapter[] {
return chapters.filter(({ chapter }) => Chapters.isDownloaded(chapter));
}
static getNonDownloaded<Chapter extends ChapterWithMetaType>(chapters: Chapter[]): Chapter[] {
return chapters.filter(({ chapter }) => !Chapters.isDownloaded(chapter));
}
static getDownloadable<Chapter extends ChapterWithMetaType>(chapters: Chapter[]): Chapter[] {
return chapters.filter(({ chapter, downloadChapter }) => !Chapters.isDownloaded(chapter) && !downloadChapter);
}
static getBookmarked<Chapter extends ChapterWithMetaType>(chapters: Chapter[]): Chapter[] {
return chapters.filter(({ chapter }) => Chapters.isBookmarked(chapter));
}
static getNonBookmarked<Chapter extends ChapterWithMetaType>(chapters: Chapter[]): Chapter[] {
return chapters.filter(({ chapter }) => !Chapters.isBookmarked(chapter));
}
static getRead<Chapter extends ChapterWithMetaType>(chapters: Chapter[]): Chapter[] {
return chapters.filter(({ chapter }) => Chapters.isRead(chapter));
}
static getNonRead<Chapter extends ChapterWithMetaType>(chapters: Chapter[]): Chapter[] {
return chapters.filter(({ chapter }) => !Chapters.isRead(chapter));
}
}

View File

@@ -15,7 +15,6 @@ import {
UpdateMangaCategoriesPatchInput, UpdateMangaCategoriesPatchInput,
} from '@/lib/graphql/generated/graphql.ts'; } from '@/lib/graphql/generated/graphql.ts';
import { Chapters } from '@/lib/data/Chapters.ts'; import { Chapters } from '@/lib/data/Chapters.ts';
import { getMetadataServerSettings } from '@/util/metadataServerSettings.ts';
import { makeToast } from '@/components/util/Toast.tsx'; import { makeToast } from '@/components/util/Toast.tsx';
export type MangaAction = export type MangaAction =
@@ -26,33 +25,54 @@ export type MangaAction =
| 'remove_from_library' | 'remove_from_library'
| 'change_categories'; | 'change_categories';
const actionToTranslationKey: { export const actionToTranslationKey: {
[key in MangaAction]: { [key in MangaAction]: {
action: {
selected: TranslationKey;
};
success: TranslationKey; success: TranslationKey;
error: TranslationKey; error: TranslationKey;
}; };
} = { } = {
download: { download: {
action: {
selected: 'chapter.action.download.add.button.selected',
},
success: 'chapter.action.download.add.label.success', success: 'chapter.action.download.add.label.success',
error: 'chapter.action.download.add.label.error', error: 'chapter.action.download.add.label.error',
}, },
delete: { delete: {
action: {
selected: 'chapter.action.download.delete.button.selected',
},
success: 'chapter.action.download.delete.label.success', success: 'chapter.action.download.delete.label.success',
error: 'chapter.action.download.delete.label.error', error: 'chapter.action.download.delete.label.error',
}, },
mark_as_read: { mark_as_read: {
action: {
selected: 'chapter.action.mark_as_read.add.button.selected',
},
success: 'chapter.action.mark_as_read.add.label.success', success: 'chapter.action.mark_as_read.add.label.success',
error: 'chapter.action.mark_as_read.add.label.error', error: 'chapter.action.mark_as_read.add.label.error',
}, },
mark_as_unread: { mark_as_unread: {
action: {
selected: 'chapter.action.mark_as_read.remove.button.selected',
},
success: 'chapter.action.mark_as_read.remove.label.success', success: 'chapter.action.mark_as_read.remove.label.success',
error: 'chapter.action.mark_as_read.remove.label.error', error: 'chapter.action.mark_as_read.remove.label.error',
}, },
remove_from_library: { remove_from_library: {
action: {
selected: 'manga.action.library.remove.button.selected',
},
success: 'manga.action.library.remove.label.success', success: 'manga.action.library.remove.label.success',
error: 'manga.action.library.remove.label.error', error: 'manga.action.library.remove.label.error',
}, },
change_categories: { change_categories: {
action: {
selected: 'manga.action.category.button.selected',
},
success: 'manga.action.category.label.success', success: 'manga.action.category.label.success',
error: 'manga.action.category.label.error', error: 'manga.action.category.label.error',
}, },
@@ -124,45 +144,22 @@ export class Mangas {
static async downloadChapters(mangaIds: number[]): Promise<void> { static async downloadChapters(mangaIds: number[]): Promise<void> {
const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isDownloaded: false }); const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isDownloaded: false });
return Mangas.executeAction( return Chapters.download(Chapters.getIds(chapters));
'download',
chapters.length,
() => requestManager.addChaptersToDownloadQueue(Chapters.getIds(chapters)).response,
);
} }
static async deleteChapters(mangaIds: number[]): Promise<void> { static async deleteChapters(mangaIds: number[]): Promise<void> {
const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isDownloaded: true }); const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isDownloaded: true });
return Mangas.executeAction( return Chapters.delete(Chapters.getIds(chapters));
'delete',
chapters.length,
() => requestManager.deleteDownloadedChapters(Chapters.getIds(chapters)).response,
);
} }
static async markAsRead(mangaIds: number[], deleteChapters: boolean = false): Promise<void> { static async markAsRead(mangaIds: number[], wasManuallyMarkedAsRead: boolean = false): Promise<void> {
const [chapters, { deleteChaptersWithBookmark }] = await Promise.all([ const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isRead: false });
Mangas.getChapterIdsWithState(mangaIds, { isRead: false }), return Chapters.markAsRead(chapters, wasManuallyMarkedAsRead);
getMetadataServerSettings(),
]);
const chapterIdsToDelete = deleteChapters
? Chapters.getIds(Chapters.getAutoDeletable(chapters, deleteChaptersWithBookmark))
: [];
return Mangas.executeAction(
'mark_as_read',
chapterIdsToDelete.length,
() =>
requestManager.updateChapters(Chapters.getIds(chapters), { isRead: true, chapterIdsToDelete }).response,
);
} }
static async markAsUnread(mangaIds: number[]): Promise<void> { static async markAsUnread(mangaIds: number[]): Promise<void> {
const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isRead: true }); const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isRead: true });
return Mangas.executeAction( return Chapters.markAsUnread(Chapters.getIds(chapters));
'mark_as_unread',
chapters.length,
() => requestManager.updateChapters(Chapters.getIds(chapters), { isRead: false }).response,
);
} }
static async removeFromLibrary(mangaIds: number[]): Promise<void> { static async removeFromLibrary(mangaIds: number[]): Promise<void> {
@@ -199,13 +196,13 @@ export class Mangas {
action: Action, action: Action,
mangaIds: number[], mangaIds: number[],
{ {
autoDeleteChapters, wasManuallyMarkedAsRead,
changeCategoriesPatch, changeCategoriesPatch,
}: Action extends 'mark_as_read' }: Action extends 'mark_as_read'
? { autoDeleteChapters: boolean; changeCategoriesPatch?: never } ? { wasManuallyMarkedAsRead: boolean; changeCategoriesPatch?: never }
: Action extends 'change_categories' : Action extends 'change_categories'
? { autoDeleteChapters?: never; changeCategoriesPatch: UpdateMangaCategoriesPatchInput } ? { wasManuallyMarkedAsRead?: never; changeCategoriesPatch: UpdateMangaCategoriesPatchInput }
: { autoDeleteChapters?: boolean; changeCategoriesPatch?: UpdateMangaCategoriesPatchInput }, : { wasManuallyMarkedAsRead?: boolean; changeCategoriesPatch?: UpdateMangaCategoriesPatchInput },
): Promise<void> { ): Promise<void> {
switch (action) { switch (action) {
case 'download': case 'download':
@@ -213,7 +210,7 @@ export class Mangas {
case 'delete': case 'delete':
return Mangas.deleteChapters(mangaIds); return Mangas.deleteChapters(mangaIds);
case 'mark_as_read': case 'mark_as_read':
return Mangas.markAsRead(mangaIds, autoDeleteChapters!); return Mangas.markAsRead(mangaIds, wasManuallyMarkedAsRead!);
case 'mark_as_unread': case 'mark_as_unread':
return Mangas.markAsUnread(mangaIds); return Mangas.markAsUnread(mangaIds);
case 'remove_from_library': case 'remove_from_library':
@@ -221,7 +218,7 @@ export class Mangas {
case 'change_categories': case 'change_categories':
return Mangas.changeCategories(mangaIds, changeCategoriesPatch!); return Mangas.changeCategories(mangaIds, changeCategoriesPatch!);
default: default:
throw new Error(`performMangasAction::performAction: unknown action "${action}"`); throw new Error(`Mangas::performAction: unknown action "${action}"`);
} }
} }
} }

View File

@@ -1452,6 +1452,13 @@ export class RequestManager {
return this.doRequest(GQLMethod.USE_QUERY, GET_CHAPTERS, variables, options); return this.doRequest(GQLMethod.USE_QUERY, GET_CHAPTERS, variables, options);
} }
public getChapters(
variables: GetChaptersQueryVariables,
options?: QueryOptions<GetChaptersQueryVariables, GetChaptersQuery>,
): AbortabaleApolloQueryResponse<GetChaptersQuery> {
return this.doRequest(GQLMethod.QUERY, GET_CHAPTERS, variables, options);
}
public useGetMangaChapters( public useGetMangaChapters(
mangaId: number | string, mangaId: number | string,
options?: QueryHookOptions<GetChaptersQuery, GetChaptersQueryVariables>, options?: QueryHookOptions<GetChaptersQuery, GetChaptersQueryVariables>,