Add chapters download menu

This commit is contained in:
schroda
2024-04-20 01:40:41 +02:00
parent 6b066fe8ca
commit 52e077ff19
11 changed files with 434 additions and 29 deletions

View File

@@ -18,6 +18,8 @@ import { useTranslation } from 'react-i18next';
import IconButton from '@mui/material/IconButton';
import DownloadIcon from '@mui/icons-material/Download';
import DoneAllIcon from '@mui/icons-material/DoneAll';
import PopupState, { bindMenu, bindTrigger } from 'material-ui-popup-state';
import Menu from '@mui/material/Menu';
import { TChapter, TManga } from '@/typings.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { ChapterCard } from '@/components/chapter/ChapterCard.tsx';
@@ -33,7 +35,7 @@ import { SelectableCollectionSelectAll } from '@/components/collection/Selectabl
import { Chapters } from '@/lib/data/Chapters.ts';
import { ChaptersWithMeta } from '@/lib/data/ChaptersWithMeta.ts';
import { ChapterActionMenuItems } from '@/components/chapter/ChapterActionMenuItems.tsx';
import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts';
import { ChaptersDownloadActionMenuItems } from '@/components/chapter/ChaptersDownloadActionMenuItems.tsx';
const ChapterListHeader = styled(Stack)(({ theme }) => ({
margin: 8,
@@ -168,18 +170,26 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
<DoneAllIcon />
</IconButton>
</Tooltip>
<Tooltip title={t('chapter.action.download.add.label.action')}>
<IconButton
disabled={areAllChaptersDownloaded}
onClick={() =>
Chapters.download(
ChaptersWithMeta.getIds(ChaptersWithMeta.getNonDownloaded(chaptersWithMeta)),
).catch(defaultPromiseErrorHandler('ChapterList::download'))
}
>
<DownloadIcon />
</IconButton>
</Tooltip>
<PopupState variant="popover" popupId="chapterlist-download-button">
{(popupState) => (
<>
<Tooltip title={t('chapter.action.download.add.label.action')}>
<IconButton disabled={areAllChaptersDownloaded} {...bindTrigger(popupState)}>
<DownloadIcon />
</IconButton>
</Tooltip>
{popupState.isOpen && (
<Menu {...bindMenu(popupState)}>
<ChaptersDownloadActionMenuItems
mangaIds={[manga.id]}
closeMenu={popupState.close}
/>
</Menu>
)}
</>
)}
</PopupState>
<ChaptersToolbarMenu options={options} optionsDispatch={dispatch} />
<SelectableCollectionSelectAll
areAllItemsSelected={areAllItemsSelected}

View File

@@ -0,0 +1,70 @@
/*
* 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 MenuItem from '@mui/material/MenuItem';
import { useTranslation } from 'react-i18next';
import { TManga } from '@/typings.ts';
import { useMetadataServerSettings } from '@/lib/metadata/metadataServerSettings.ts';
import { Mangas } from '@/lib/data/Mangas.ts';
import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts';
const DownloadRange = {
NEXT_1: 1,
NEXT_5: 5,
NEXT_10: 10,
NEXT_25: 25,
UNREAD: undefined,
};
export const ChaptersDownloadActionMenuItems = ({
mangaIds,
closeMenu,
}: {
mangaIds: TManga['id'][];
closeMenu: () => void;
}) => {
const { t } = useTranslation();
const {
settings: { downloadAheadLimit },
} = useMetadataServerSettings();
const handleSelect = (size?: number, onlyUnread: boolean = true) => {
Mangas.performAction('download', mangaIds, {
onlyUnread,
size,
}).catch(defaultPromiseErrorHandler('ChapterDownloadButton::handleSelect'));
closeMenu?.();
};
return (
<>
<MenuItem onClick={() => handleSelect(DownloadRange.NEXT_1)}>
{t('chapter.action.download.add.label.next')}
</MenuItem>
<MenuItem onClick={() => handleSelect(DownloadRange.NEXT_5)}>
{t('chapter.action.download.add.label.next_five')}
</MenuItem>
<MenuItem onClick={() => handleSelect(DownloadRange.NEXT_10)}>
{t('chapter.action.download.add.label.next_ten')}
</MenuItem>
<MenuItem onClick={() => handleSelect(DownloadRange.NEXT_25)}>
{t('chapter.action.download.add.label.next_twentyfive')}
</MenuItem>
<MenuItem onClick={() => handleSelect(downloadAheadLimit)}>
{t('chapter.action.download.add.label.ahead', { count: downloadAheadLimit })}
</MenuItem>
<MenuItem onClick={() => handleSelect(DownloadRange.UNREAD)}>
{t('chapter.action.download.add.label.unread')}
</MenuItem>
<MenuItem onClick={() => handleSelect(downloadAheadLimit)}>
{t('chapter.action.download.add.label.all', { onlyUnread: false })}
</MenuItem>
</>
);
};

View File

@@ -27,6 +27,8 @@ import { createGetMenuItemTitle, createIsMenuItemDisabled, createShouldShowMenuI
import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts';
import { TrackManga } from '@/components/tracker/TrackManga.tsx';
import { useCategorySelect } from '@/components/navbar/action/useCategorySelect.tsx';
import { ChaptersDownloadActionMenuItems } from '@/components/chapter/ChaptersDownloadActionMenuItems.tsx';
import { NestedMenuItem } from '@/components/menu/NestedMenuItem.tsx';
const ACTION_DISABLES_SELECTION_MODE: MangaAction[] = ['remove_from_library'] as const;
@@ -110,12 +112,17 @@ export const MangaActionMenuItems = ({
<MenuItem onClick={handleSelect} Icon={CheckBoxOutlineBlank} title={t('chapter.action.label.select')} />
)}
{shouldShowMenuItem(!isFullyDownloaded) && (
<MenuItem
Icon={Download}
<NestedMenuItem
disabled={isMenuItemDisabled(!downloadableMangas.length)}
onClick={() => performAction('download', downloadableMangas)}
title={getMenuItemTitle('download', downloadableMangas.length)}
/>
LeftIcon={Download}
label={getMenuItemTitle('download', downloadableMangas.length)}
parentMenuOpen
>
<ChaptersDownloadActionMenuItems
mangaIds={isSingleMode ? [manga.id] : Mangas.getIds(selectedMangas)}
closeMenu={() => onClose(true)}
/>
</NestedMenuItem>
)}
{shouldShowMenuItem(hasDownloadedChapters) && (
<MenuItem

View File

@@ -0,0 +1,51 @@
/*
* 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/.
*/
/*
* src: https://github.com/webzep/mui-nested-menu (2024-04-20 01:42)
*/
import ListItemIcon from '@mui/material/ListItemIcon';
import MenuItem, { MenuItemProps as MuiMenuItemProps } from '@mui/material/MenuItem';
import { SxProps, Theme } from '@mui/material/styles';
import React, { forwardRef, RefObject } from 'react';
import { OverridableComponent } from '@mui/material/OverridableComponent';
import { SvgIconTypeMap } from '@mui/material/SvgIcon';
import ListItemText from '@mui/material/ListItemText';
type IconMenuItemProps = {
MenuItemProps?: MuiMenuItemProps;
className?: string;
disabled?: boolean;
label?: string;
renderLabel?: () => React.ReactNode;
LeftIcon?: OverridableComponent<SvgIconTypeMap> & { muiName: string };
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
ref?: RefObject<HTMLLIElement>;
RightIcon?: OverridableComponent<SvgIconTypeMap> & { muiName: string };
sx?: SxProps<Theme>;
};
export const IconMenuItem = forwardRef<HTMLLIElement, IconMenuItemProps>(
({ MenuItemProps, className, label, LeftIcon, renderLabel, RightIcon, ...props }, ref) => (
<MenuItem {...MenuItemProps} ref={ref}>
{LeftIcon && (
<ListItemIcon>
<LeftIcon fontSize="small" />
</ListItemIcon>
)}
<ListItemText>{label}</ListItemText>
{RightIcon && (
<ListItemIcon style={{ minWidth: 0 }}>
<RightIcon fontSize="small" />
</ListItemIcon>
)}
</MenuItem>
),
);

View File

@@ -0,0 +1,219 @@
/*
* 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/.
*/
/*
* src: https://github.com/webzep/mui-nested-menu (2024-04-20 01:42)
*
* with a few changes to fix a bug on mobile devices where opening the sub menu immediately triggered the on click of the underlying menu item
*/
import Menu, { MenuProps as MuiMenuProps } from '@mui/material/Menu';
import { MenuItemProps as MuiMenuItemProps } from '@mui/material/MenuItem';
import {
ElementType,
forwardRef,
HTMLAttributes,
KeyboardEvent,
FocusEvent,
MouseEvent,
ReactNode,
RefAttributes,
useImperativeHandle,
useRef,
useState,
Ref,
} from 'react';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import Box from '@mui/material/Box';
import { OverridableComponent } from '@mui/material/OverridableComponent';
import { SvgIconTypeMap } from '@mui/material/SvgIcon';
import { isMobile } from 'react-device-detect';
import { IconMenuItem } from '@/components/menu/IconMenuItem.tsx';
export type NestedMenuItemProps = Omit<MuiMenuItemProps, 'button'> & {
parentMenuOpen: boolean;
component?: ElementType;
label?: string;
renderLabel?: () => ReactNode;
RightIcon?: OverridableComponent<SvgIconTypeMap> & { muiName: string };
LeftIcon?: OverridableComponent<SvgIconTypeMap> & { muiName: string };
children?: ReactNode;
className?: string;
tabIndex?: number;
disabled?: boolean;
ContainerProps?: HTMLAttributes<HTMLElement> & RefAttributes<HTMLElement>;
MenuProps?: Partial<Omit<MuiMenuProps, 'children'>>;
button?: true | undefined;
};
const NestedMenuItem = forwardRef<HTMLLIElement | null, NestedMenuItemProps>((props, ref) => {
const {
parentMenuOpen,
label,
renderLabel,
RightIcon = ChevronRightIcon,
LeftIcon,
children,
className,
tabIndex: tabIndexProp,
ContainerProps: ContainerPropsProp = {},
MenuProps,
...MenuItemProps
} = props;
const { ref: containerRefProp, ...ContainerProps } = ContainerPropsProp;
const menuItemRef = useRef<HTMLLIElement | null>(null);
useImperativeHandle(ref, () => menuItemRef.current!);
const containerRef = useRef<HTMLElement>(null);
useImperativeHandle(containerRefProp as Ref<HTMLElement | null>, () => containerRef.current as HTMLElement);
const menuContainerRef = useRef<HTMLDivElement | null>(null);
const [isSubMenuOpen, setIsSubMenuOpen] = useState(false);
const handleMouseEnter = (e: MouseEvent<HTMLElement>) => {
if (isMobile) {
return;
}
setIsSubMenuOpen(true);
if (ContainerProps.onMouseEnter) {
ContainerProps.onMouseEnter(e);
}
};
const handleMouseLeave = (e: MouseEvent<HTMLElement>) => {
setIsSubMenuOpen(false);
if (ContainerProps.onMouseLeave) {
ContainerProps.onMouseLeave(e);
}
};
// Check if any immediate children are active
const isSubmenuFocused = () => {
const active = containerRef.current?.ownerDocument.activeElement ?? null;
if (menuContainerRef.current == null) {
return false;
}
for (const child of menuContainerRef.current.children) {
if (child === active) {
return true;
}
}
return false;
};
const handleFocus = (e: FocusEvent<HTMLElement>) => {
if (isMobile) {
return;
}
if (e.target === containerRef.current) {
setIsSubMenuOpen(true);
}
if (ContainerProps.onFocus) {
ContainerProps.onFocus(e);
}
};
const handleClick = (e: MouseEvent<HTMLElement>) => {
setIsSubMenuOpen(!isSubMenuOpen);
if (ContainerProps.onClick) {
ContainerProps.onClick(e);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
return;
}
if (isSubmenuFocused()) {
e.stopPropagation();
}
const active = containerRef.current?.ownerDocument.activeElement;
if (e.key === 'ArrowLeft' && isSubmenuFocused()) {
containerRef.current?.focus();
}
if (e.key === 'ArrowRight' && e.target === containerRef.current && e.target === active) {
const firstChild = menuContainerRef.current?.children[0] as HTMLDivElement;
firstChild?.focus();
}
};
const open = isSubMenuOpen && parentMenuOpen;
// Root element must have a `tabIndex` attribute for keyboard navigation
let tabIndex;
if (!props.disabled) {
tabIndex = tabIndexProp !== undefined ? tabIndexProp : -1;
}
return (
<Box
{...ContainerProps}
ref={containerRef}
onFocus={handleFocus}
onClick={handleClick}
tabIndex={tabIndex}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onKeyDown={handleKeyDown}
>
<IconMenuItem
MenuItemProps={MenuItemProps}
className={className}
ref={menuItemRef}
LeftIcon={LeftIcon}
RightIcon={RightIcon}
label={label}
renderLabel={renderLabel}
/>
<Menu
// Set pointer events to 'none' to prevent the invisible Popover div
// from capturing events for clicks and hovers
style={{ pointerEvents: 'none' }}
anchorEl={menuItemRef.current}
anchorOrigin={{
horizontal: 'right',
vertical: 'top',
}}
transformOrigin={{
horizontal: 'left',
vertical: 'top',
}}
open={open}
autoFocus={false}
disableAutoFocus
disableEnforceFocus
onClose={() => {
setIsSubMenuOpen(false);
}}
{...MenuProps}
>
<Box ref={menuContainerRef} style={{ pointerEvents: 'auto' }}>
{children}
</Box>
</Menu>
</Box>
);
});
NestedMenuItem.displayName = 'NestedMenuItem';
export { NestedMenuItem };