Add chapters download menu
This commit is contained in:
@@ -43,6 +43,7 @@
|
|||||||
"i18next-browser-languagedetector": "7.2.1",
|
"i18next-browser-languagedetector": "7.2.1",
|
||||||
"i18next-http-backend": "2.5.0",
|
"i18next-http-backend": "2.5.0",
|
||||||
"material-ui-popup-state": "5.1.0",
|
"material-ui-popup-state": "5.1.0",
|
||||||
|
"mui-nested-menu": "3.3.0",
|
||||||
"p-limit": "5.0.0",
|
"p-limit": "5.0.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-beautiful-dnd": "13.1.1",
|
"react-beautiful-dnd": "13.1.1",
|
||||||
|
|||||||
@@ -70,10 +70,17 @@
|
|||||||
},
|
},
|
||||||
"label": {
|
"label": {
|
||||||
"action": "Download",
|
"action": "Download",
|
||||||
|
"ahead": "$t(download.settings.download_ahead.title) ({{count}})",
|
||||||
|
"all": "$t(extension.language.all)",
|
||||||
"error_one": "Could not add the download",
|
"error_one": "Could not add the download",
|
||||||
"error_other": "Could not add downloads",
|
"error_other": "Could not add downloads",
|
||||||
|
"next": "$t(reader.button.next_chapter)",
|
||||||
|
"next_five": "Next 5 chapters",
|
||||||
|
"next_ten": "Next 10 chapters",
|
||||||
|
"next_twentyfive": "Next 25 chapters",
|
||||||
"success_one": "Download added",
|
"success_one": "Download added",
|
||||||
"success_other": "{{count}} downloads added"
|
"success_other": "{{count}} downloads added",
|
||||||
|
"unread": "$t(global.filter.label.unread)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ 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 PopupState, { bindMenu, bindTrigger } from 'material-ui-popup-state';
|
||||||
|
import Menu from '@mui/material/Menu';
|
||||||
import { TChapter, TManga } from '@/typings.ts';
|
import { TChapter, TManga } from '@/typings.ts';
|
||||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||||
import { ChapterCard } from '@/components/chapter/ChapterCard.tsx';
|
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 { Chapters } from '@/lib/data/Chapters.ts';
|
||||||
import { ChaptersWithMeta } from '@/lib/data/ChaptersWithMeta.ts';
|
import { ChaptersWithMeta } from '@/lib/data/ChaptersWithMeta.ts';
|
||||||
import { ChapterActionMenuItems } from '@/components/chapter/ChapterActionMenuItems.tsx';
|
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 }) => ({
|
const ChapterListHeader = styled(Stack)(({ theme }) => ({
|
||||||
margin: 8,
|
margin: 8,
|
||||||
@@ -168,18 +170,26 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
|
|||||||
<DoneAllIcon />
|
<DoneAllIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t('chapter.action.download.add.label.action')}>
|
<PopupState variant="popover" popupId="chapterlist-download-button">
|
||||||
<IconButton
|
{(popupState) => (
|
||||||
disabled={areAllChaptersDownloaded}
|
<>
|
||||||
onClick={() =>
|
<Tooltip title={t('chapter.action.download.add.label.action')}>
|
||||||
Chapters.download(
|
<IconButton disabled={areAllChaptersDownloaded} {...bindTrigger(popupState)}>
|
||||||
ChaptersWithMeta.getIds(ChaptersWithMeta.getNonDownloaded(chaptersWithMeta)),
|
<DownloadIcon />
|
||||||
).catch(defaultPromiseErrorHandler('ChapterList::download'))
|
</IconButton>
|
||||||
}
|
</Tooltip>
|
||||||
>
|
{popupState.isOpen && (
|
||||||
<DownloadIcon />
|
<Menu {...bindMenu(popupState)}>
|
||||||
</IconButton>
|
<ChaptersDownloadActionMenuItems
|
||||||
</Tooltip>
|
mangaIds={[manga.id]}
|
||||||
|
closeMenu={popupState.close}
|
||||||
|
/>
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PopupState>
|
||||||
|
|
||||||
<ChaptersToolbarMenu options={options} optionsDispatch={dispatch} />
|
<ChaptersToolbarMenu options={options} optionsDispatch={dispatch} />
|
||||||
<SelectableCollectionSelectAll
|
<SelectableCollectionSelectAll
|
||||||
areAllItemsSelected={areAllItemsSelected}
|
areAllItemsSelected={areAllItemsSelected}
|
||||||
|
|||||||
70
src/components/chapter/ChaptersDownloadActionMenuItems.tsx
Normal file
70
src/components/chapter/ChaptersDownloadActionMenuItems.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -27,6 +27,8 @@ import { createGetMenuItemTitle, createIsMenuItemDisabled, createShouldShowMenuI
|
|||||||
import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts';
|
import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts';
|
||||||
import { TrackManga } from '@/components/tracker/TrackManga.tsx';
|
import { TrackManga } from '@/components/tracker/TrackManga.tsx';
|
||||||
import { useCategorySelect } from '@/components/navbar/action/useCategorySelect.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;
|
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')} />
|
<MenuItem onClick={handleSelect} Icon={CheckBoxOutlineBlank} title={t('chapter.action.label.select')} />
|
||||||
)}
|
)}
|
||||||
{shouldShowMenuItem(!isFullyDownloaded) && (
|
{shouldShowMenuItem(!isFullyDownloaded) && (
|
||||||
<MenuItem
|
<NestedMenuItem
|
||||||
Icon={Download}
|
|
||||||
disabled={isMenuItemDisabled(!downloadableMangas.length)}
|
disabled={isMenuItemDisabled(!downloadableMangas.length)}
|
||||||
onClick={() => performAction('download', downloadableMangas)}
|
LeftIcon={Download}
|
||||||
title={getMenuItemTitle('download', downloadableMangas.length)}
|
label={getMenuItemTitle('download', downloadableMangas.length)}
|
||||||
/>
|
parentMenuOpen
|
||||||
|
>
|
||||||
|
<ChaptersDownloadActionMenuItems
|
||||||
|
mangaIds={isSingleMode ? [manga.id] : Mangas.getIds(selectedMangas)}
|
||||||
|
closeMenu={() => onClose(true)}
|
||||||
|
/>
|
||||||
|
</NestedMenuItem>
|
||||||
)}
|
)}
|
||||||
{shouldShowMenuItem(hasDownloadedChapters) && (
|
{shouldShowMenuItem(hasDownloadedChapters) && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|||||||
51
src/components/menu/IconMenuItem.tsx
Normal file
51
src/components/menu/IconMenuItem.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
);
|
||||||
219
src/components/menu/NestedMenuItem.tsx
Normal file
219
src/components/menu/NestedMenuItem.tsx
Normal 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 };
|
||||||
@@ -115,6 +115,10 @@ export type MangaThumbnailInfo = Pick<TManga, 'thumbnailUrl' | 'thumbnailUrlLast
|
|||||||
|
|
||||||
export type MigrateMode = 'copy' | 'migrate';
|
export type MigrateMode = 'copy' | 'migrate';
|
||||||
|
|
||||||
|
type DownloadChaptersOptions = {
|
||||||
|
size?: number;
|
||||||
|
onlyUnread?: boolean;
|
||||||
|
};
|
||||||
type MarkAsReadOptions = { wasManuallyMarkedAsRead: boolean };
|
type MarkAsReadOptions = { wasManuallyMarkedAsRead: boolean };
|
||||||
type ChangeCategoriesOptions = { changeCategoriesPatch: UpdateMangaCategoriesPatchInput };
|
type ChangeCategoriesOptions = { changeCategoriesPatch: UpdateMangaCategoriesPatchInput };
|
||||||
type MigrateOptions = {
|
type MigrateOptions = {
|
||||||
@@ -127,14 +131,24 @@ type MigrateOptions = {
|
|||||||
|
|
||||||
type MarkAsReadActionOption = MarkAsReadOptions &
|
type MarkAsReadActionOption = MarkAsReadOptions &
|
||||||
PropertiesNever<ChangeCategoriesOptions> &
|
PropertiesNever<ChangeCategoriesOptions> &
|
||||||
PropertiesNever<MigrateOptions>;
|
PropertiesNever<MigrateOptions> &
|
||||||
|
PropertiesNever<DownloadChaptersOptions>;
|
||||||
type ChangeCategoriesActionOption = PropertiesNever<MarkAsReadOptions> &
|
type ChangeCategoriesActionOption = PropertiesNever<MarkAsReadOptions> &
|
||||||
ChangeCategoriesOptions &
|
ChangeCategoriesOptions &
|
||||||
PropertiesNever<MigrateOptions>;
|
PropertiesNever<MigrateOptions> &
|
||||||
|
PropertiesNever<DownloadChaptersOptions>;
|
||||||
type MigrateActionOption = PropertiesNever<MarkAsReadOptions> &
|
type MigrateActionOption = PropertiesNever<MarkAsReadOptions> &
|
||||||
PropertiesNever<ChangeCategoriesOptions> &
|
PropertiesNever<ChangeCategoriesOptions> &
|
||||||
MigrateOptions;
|
MigrateOptions &
|
||||||
type DefaultActionOption = Partial<MarkAsReadOptions> & Partial<ChangeCategoriesOptions> & Partial<MigrateOptions>;
|
PropertiesNever<DownloadChaptersOptions>;
|
||||||
|
type DownloadActionOption = PropertiesNever<MarkAsReadOptions> &
|
||||||
|
PropertiesNever<ChangeCategoriesOptions> &
|
||||||
|
PropertiesNever<MigrateOptions> &
|
||||||
|
DownloadChaptersOptions;
|
||||||
|
type DefaultActionOption = Partial<MarkAsReadOptions> &
|
||||||
|
Partial<ChangeCategoriesOptions> &
|
||||||
|
Partial<MigrateOptions> &
|
||||||
|
Partial<DownloadChaptersOptions>;
|
||||||
|
|
||||||
type PerformActionOptions<Action extends MangaAction> = Action extends 'mark_as_read'
|
type PerformActionOptions<Action extends MangaAction> = Action extends 'mark_as_read'
|
||||||
? MarkAsReadActionOption
|
? MarkAsReadActionOption
|
||||||
@@ -142,7 +156,9 @@ type PerformActionOptions<Action extends MangaAction> = Action extends 'mark_as_
|
|||||||
? ChangeCategoriesActionOption
|
? ChangeCategoriesActionOption
|
||||||
: Action extends 'migrate'
|
: Action extends 'migrate'
|
||||||
? MigrateActionOption
|
? MigrateActionOption
|
||||||
: DefaultActionOption;
|
: Action extends 'download'
|
||||||
|
? DownloadActionOption
|
||||||
|
: DefaultActionOption;
|
||||||
|
|
||||||
export class Mangas {
|
export class Mangas {
|
||||||
static getIds(mangas: { id: number }[]): number[] {
|
static getIds(mangas: { id: number }[]): number[] {
|
||||||
@@ -227,9 +243,25 @@ export class Mangas {
|
|||||||
return data.chapters.nodes;
|
return data.chapters.nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async downloadChapters(mangaIds: number[]): Promise<void> {
|
static async downloadChapters(
|
||||||
const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isDownloaded: false });
|
mangaIds: number[],
|
||||||
return Chapters.download(Chapters.getIds(chapters));
|
{ size, onlyUnread }: DownloadChaptersOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const chapters = await Mangas.getChapterIdsWithState(mangaIds, {
|
||||||
|
isRead: onlyUnread ? false : undefined,
|
||||||
|
isDownloaded: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!chapters.length) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
const mangaIdToChapters = Object.groupBy(chapters, ({ mangaId }) => mangaId);
|
||||||
|
const chapterIdsToDownload = Object.values(mangaIdToChapters)
|
||||||
|
.map((mangaChapters) => mangaChapters!.slice(0, size)) // the result of groupBy can't result in undefined values
|
||||||
|
.flat();
|
||||||
|
|
||||||
|
return Chapters.download(Chapters.getIds(chapterIdsToDownload));
|
||||||
}
|
}
|
||||||
|
|
||||||
static async deleteChapters(mangaIds: number[]): Promise<void> {
|
static async deleteChapters(mangaIds: number[]): Promise<void> {
|
||||||
@@ -380,12 +412,14 @@ export class Mangas {
|
|||||||
wasManuallyMarkedAsRead,
|
wasManuallyMarkedAsRead,
|
||||||
changeCategoriesPatch,
|
changeCategoriesPatch,
|
||||||
mangaIdToMigrateTo,
|
mangaIdToMigrateTo,
|
||||||
|
onlyUnread,
|
||||||
|
size,
|
||||||
...migrateOptions
|
...migrateOptions
|
||||||
}: PerformActionOptions<Action>,
|
}: PerformActionOptions<Action>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'download':
|
case 'download':
|
||||||
return Mangas.downloadChapters(mangaIds);
|
return Mangas.downloadChapters(mangaIds, { onlyUnread, size });
|
||||||
case 'delete':
|
case 'delete':
|
||||||
return Mangas.deleteChapters(mangaIds);
|
return Mangas.deleteChapters(mangaIds);
|
||||||
case 'mark_as_read':
|
case 'mark_as_read':
|
||||||
|
|||||||
@@ -3117,7 +3117,7 @@ export type GetMangasChapterIdsWithStateQueryVariables = Exact<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
export type GetMangasChapterIdsWithStateQuery = { __typename?: 'Query', chapters: { __typename?: 'ChapterNodeList', nodes: Array<{ __typename?: 'ChapterType', id: number, isDownloaded: boolean, isRead: boolean, isBookmarked: boolean }> } };
|
export type GetMangasChapterIdsWithStateQuery = { __typename?: 'Query', chapters: { __typename?: 'ChapterNodeList', nodes: Array<{ __typename?: 'ChapterType', id: number, isDownloaded: boolean, isRead: boolean, isBookmarked: boolean, mangaId: number }> } };
|
||||||
|
|
||||||
export type GetDownloadStatusQueryVariables = Exact<{ [key: string]: never; }>;
|
export type GetDownloadStatusQueryVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export const GET_MANGAS_CHAPTER_IDS_WITH_STATE = gql`
|
|||||||
isDownloaded
|
isDownloaded
|
||||||
isRead
|
isRead
|
||||||
isBookmarked
|
isBookmarked
|
||||||
|
mangaId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5417,6 +5417,11 @@ ms@^2.1.1:
|
|||||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
||||||
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
|
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
|
||||||
|
|
||||||
|
mui-nested-menu@3.3.0:
|
||||||
|
version "3.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/mui-nested-menu/-/mui-nested-menu-3.3.0.tgz#83a292d94515a5bcd8acdfda5f2a0e8ffc428547"
|
||||||
|
integrity sha512-0tA+T8X6w5F2pgWLPC6vfXrVnc7zvLtgAR5Tmy7hIa73YjTuZfLaVfiDqtCNQNtLK/OcechtF04yx/G7HTjHVA==
|
||||||
|
|
||||||
mute-stream@0.0.8:
|
mute-stream@0.0.8:
|
||||||
version "0.0.8"
|
version "0.0.8"
|
||||||
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
|
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
|
||||||
|
|||||||
Reference in New Issue
Block a user