From 52e077ff194dbcbb43451ba544f14e55a5f892da Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Sat, 20 Apr 2024 01:40:41 +0200 Subject: [PATCH] Add chapters download menu --- package.json | 1 + public/locales/en.json | 9 +- src/components/chapter/ChapterList.tsx | 36 +-- .../ChaptersDownloadActionMenuItems.tsx | 70 ++++++ src/components/manga/MangaActionMenuItems.tsx | 17 +- src/components/menu/IconMenuItem.tsx | 51 ++++ src/components/menu/NestedMenuItem.tsx | 219 ++++++++++++++++++ src/lib/data/Mangas.ts | 52 ++++- src/lib/graphql/generated/graphql.ts | 2 +- src/lib/graphql/queries/ChapterQuery.ts | 1 + yarn.lock | 5 + 11 files changed, 434 insertions(+), 29 deletions(-) create mode 100644 src/components/chapter/ChaptersDownloadActionMenuItems.tsx create mode 100644 src/components/menu/IconMenuItem.tsx create mode 100644 src/components/menu/NestedMenuItem.tsx diff --git a/package.json b/package.json index 604567f9..65d1681f 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "i18next-browser-languagedetector": "7.2.1", "i18next-http-backend": "2.5.0", "material-ui-popup-state": "5.1.0", + "mui-nested-menu": "3.3.0", "p-limit": "5.0.0", "react": "18.2.0", "react-beautiful-dnd": "13.1.1", diff --git a/public/locales/en.json b/public/locales/en.json index e15fb8a9..37ef5534 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -70,10 +70,17 @@ }, "label": { "action": "Download", + "ahead": "$t(download.settings.download_ahead.title) ({{count}})", + "all": "$t(extension.language.all)", "error_one": "Could not add the download", "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_other": "{{count}} downloads added" + "success_other": "{{count}} downloads added", + "unread": "$t(global.filter.label.unread)" } }, "delete": { diff --git a/src/components/chapter/ChapterList.tsx b/src/components/chapter/ChapterList.tsx index c640d1bc..d4c27134 100644 --- a/src/components/chapter/ChapterList.tsx +++ b/src/components/chapter/ChapterList.tsx @@ -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 = ({ manga, isRefreshing }) => { - - - Chapters.download( - ChaptersWithMeta.getIds(ChaptersWithMeta.getNonDownloaded(chaptersWithMeta)), - ).catch(defaultPromiseErrorHandler('ChapterList::download')) - } - > - - - + + {(popupState) => ( + <> + + + + + + {popupState.isOpen && ( + + + + )} + + )} + + 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 ( + <> + handleSelect(DownloadRange.NEXT_1)}> + {t('chapter.action.download.add.label.next')} + + handleSelect(DownloadRange.NEXT_5)}> + {t('chapter.action.download.add.label.next_five')} + + handleSelect(DownloadRange.NEXT_10)}> + {t('chapter.action.download.add.label.next_ten')} + + handleSelect(DownloadRange.NEXT_25)}> + {t('chapter.action.download.add.label.next_twentyfive')} + + handleSelect(downloadAheadLimit)}> + {t('chapter.action.download.add.label.ahead', { count: downloadAheadLimit })} + + handleSelect(DownloadRange.UNREAD)}> + {t('chapter.action.download.add.label.unread')} + + handleSelect(downloadAheadLimit)}> + {t('chapter.action.download.add.label.all', { onlyUnread: false })} + + + ); +}; diff --git a/src/components/manga/MangaActionMenuItems.tsx b/src/components/manga/MangaActionMenuItems.tsx index 62aaf1e3..88e6a881 100644 --- a/src/components/manga/MangaActionMenuItems.tsx +++ b/src/components/manga/MangaActionMenuItems.tsx @@ -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 = ({ )} {shouldShowMenuItem(!isFullyDownloaded) && ( - performAction('download', downloadableMangas)} - title={getMenuItemTitle('download', downloadableMangas.length)} - /> + LeftIcon={Download} + label={getMenuItemTitle('download', downloadableMangas.length)} + parentMenuOpen + > + onClose(true)} + /> + )} {shouldShowMenuItem(hasDownloadedChapters) && ( React.ReactNode; + LeftIcon?: OverridableComponent & { muiName: string }; + onClick?: (event: React.MouseEvent) => void; + ref?: RefObject; + RightIcon?: OverridableComponent & { muiName: string }; + sx?: SxProps; +}; + +export const IconMenuItem = forwardRef( + ({ MenuItemProps, className, label, LeftIcon, renderLabel, RightIcon, ...props }, ref) => ( + + {LeftIcon && ( + + + + )} + {label} + {RightIcon && ( + + + + )} + + ), +); diff --git a/src/components/menu/NestedMenuItem.tsx b/src/components/menu/NestedMenuItem.tsx new file mode 100644 index 00000000..d5053757 --- /dev/null +++ b/src/components/menu/NestedMenuItem.tsx @@ -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 & { + parentMenuOpen: boolean; + component?: ElementType; + label?: string; + renderLabel?: () => ReactNode; + RightIcon?: OverridableComponent & { muiName: string }; + LeftIcon?: OverridableComponent & { muiName: string }; + children?: ReactNode; + className?: string; + tabIndex?: number; + disabled?: boolean; + ContainerProps?: HTMLAttributes & RefAttributes; + MenuProps?: Partial>; + button?: true | undefined; +}; + +const NestedMenuItem = forwardRef((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(null); + useImperativeHandle(ref, () => menuItemRef.current!); + + const containerRef = useRef(null); + useImperativeHandle(containerRefProp as Ref, () => containerRef.current as HTMLElement); + + const menuContainerRef = useRef(null); + + const [isSubMenuOpen, setIsSubMenuOpen] = useState(false); + + const handleMouseEnter = (e: MouseEvent) => { + if (isMobile) { + return; + } + + setIsSubMenuOpen(true); + + if (ContainerProps.onMouseEnter) { + ContainerProps.onMouseEnter(e); + } + }; + const handleMouseLeave = (e: MouseEvent) => { + 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) => { + if (isMobile) { + return; + } + + if (e.target === containerRef.current) { + setIsSubMenuOpen(true); + } + + if (ContainerProps.onFocus) { + ContainerProps.onFocus(e); + } + }; + + const handleClick = (e: MouseEvent) => { + 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 ( + + + + { + setIsSubMenuOpen(false); + }} + {...MenuProps} + > + + {children} + + + + ); +}); + +NestedMenuItem.displayName = 'NestedMenuItem'; +export { NestedMenuItem }; diff --git a/src/lib/data/Mangas.ts b/src/lib/data/Mangas.ts index 9e4e00d6..caff090f 100644 --- a/src/lib/data/Mangas.ts +++ b/src/lib/data/Mangas.ts @@ -115,6 +115,10 @@ export type MangaThumbnailInfo = Pick & - PropertiesNever; + PropertiesNever & + PropertiesNever; type ChangeCategoriesActionOption = PropertiesNever & ChangeCategoriesOptions & - PropertiesNever; + PropertiesNever & + PropertiesNever; type MigrateActionOption = PropertiesNever & PropertiesNever & - MigrateOptions; -type DefaultActionOption = Partial & Partial & Partial; + MigrateOptions & + PropertiesNever; +type DownloadActionOption = PropertiesNever & + PropertiesNever & + PropertiesNever & + DownloadChaptersOptions; +type DefaultActionOption = Partial & + Partial & + Partial & + Partial; type PerformActionOptions = Action extends 'mark_as_read' ? MarkAsReadActionOption @@ -142,7 +156,9 @@ type PerformActionOptions = Action extends 'mark_as_ ? ChangeCategoriesActionOption : Action extends 'migrate' ? MigrateActionOption - : DefaultActionOption; + : Action extends 'download' + ? DownloadActionOption + : DefaultActionOption; export class Mangas { static getIds(mangas: { id: number }[]): number[] { @@ -227,9 +243,25 @@ export class Mangas { return data.chapters.nodes; } - static async downloadChapters(mangaIds: number[]): Promise { - const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isDownloaded: false }); - return Chapters.download(Chapters.getIds(chapters)); + static async downloadChapters( + mangaIds: number[], + { size, onlyUnread }: DownloadChaptersOptions = {}, + ): Promise { + 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 { @@ -380,12 +412,14 @@ export class Mangas { wasManuallyMarkedAsRead, changeCategoriesPatch, mangaIdToMigrateTo, + onlyUnread, + size, ...migrateOptions }: PerformActionOptions, ): Promise { switch (action) { case 'download': - return Mangas.downloadChapters(mangaIds); + return Mangas.downloadChapters(mangaIds, { onlyUnread, size }); case 'delete': return Mangas.deleteChapters(mangaIds); case 'mark_as_read': diff --git a/src/lib/graphql/generated/graphql.ts b/src/lib/graphql/generated/graphql.ts index 64ebfcc8..f6e47895 100644 --- a/src/lib/graphql/generated/graphql.ts +++ b/src/lib/graphql/generated/graphql.ts @@ -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; }>; diff --git a/src/lib/graphql/queries/ChapterQuery.ts b/src/lib/graphql/queries/ChapterQuery.ts index 3f362b45..c73898d8 100644 --- a/src/lib/graphql/queries/ChapterQuery.ts +++ b/src/lib/graphql/queries/ChapterQuery.ts @@ -72,6 +72,7 @@ export const GET_MANGAS_CHAPTER_IDS_WITH_STATE = gql` isDownloaded isRead isBookmarked + mangaId } } } diff --git a/yarn.lock b/yarn.lock index 92f2862a..fd55d70c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5417,6 +5417,11 @@ ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" 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: version "0.0.8" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"