From 446deeae06b4c8f5f189685969105b185043f0a7 Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Mon, 25 Dec 2023 21:37:45 +0100 Subject: [PATCH] Feature/library manga actions (#506) * Extend "IMangaGridProps" from "DefaultGridProps" * Move library manga filtering into hook * Add logic to select mangas * Add manga actions * Remove cache only policy for category mangas Unclear why this was added * Prevent SelectionFAB from being hidden by the mobile footer On e.g. the library page, the footer is visible and thus, the fab was not completely visible * Update categories of manga only after clicking "OK" Previously, selecting a category resulted in an immediate mutation. To be able to reuse the "CategorySelect" component to change the categories of multiple mangas at once, this behaviour is not suited. * Add action to change categories of multiple mangas * Cancel selection mode after removal from library The mangas would still be selected and other action could get performed. However, this should only be possible for mangas that are in the library * Remove unintentionally added console log Accidentally added with 980da657d99dd8d01fed82b5c80be7e3b01d0429 --- package.json | 2 + src/components/MangaCard.tsx | 482 +++++++++++------- src/components/MangaGrid.tsx | 85 ++- .../SelectableCollectionSelectMode.tsx | 49 ++ .../collection/useSelectableCollection.ts | 76 ++- src/components/library/LibraryMangaGrid.tsx | 128 +---- .../library/useGetVisibleLibraryMangas.ts | 126 +++++ src/components/manga/ChapterList.tsx | 2 +- src/components/manga/MangaActionMenu.tsx | 127 +++++ src/components/manga/MangaOptionButton.tsx | 114 +++++ .../manga/MangasSelectionFABActionItems.tsx | 105 ++++ src/components/manga/SelectionFAB.tsx | 24 +- .../navbar/action/CategorySelect.tsx | 138 +++-- src/i18n/locale/en.json | 32 +- src/lib/data/Chapters.ts | 36 ++ src/lib/data/Mangas.ts | 227 +++++++++ src/lib/graphql/generated/graphql.ts | 10 + src/lib/graphql/queries/ChapterQuery.ts | 21 + src/lib/requests/RequestManager.ts | 78 ++- src/lib/requests/client/GraphQLClient.ts | 1 - src/screens/Library.tsx | 189 +++++-- src/util/metadataServerSettings.ts | 11 + yarn.lock | 124 ++++- 23 files changed, 1760 insertions(+), 427 deletions(-) create mode 100644 src/components/collection/SelectableCollectionSelectMode.tsx create mode 100644 src/components/library/useGetVisibleLibraryMangas.ts create mode 100644 src/components/manga/MangaActionMenu.tsx create mode 100644 src/components/manga/MangaOptionButton.tsx create mode 100644 src/components/manga/MangasSelectionFABActionItems.tsx create mode 100644 src/lib/data/Chapters.ts create mode 100644 src/lib/data/Mangas.ts diff --git a/package.json b/package.json index 647141b5..d546e2ee 100644 --- a/package.json +++ b/package.json @@ -45,8 +45,10 @@ "graphql-ws": "^5.14.2", "i18next": "^23.7.6", "i18next-browser-languagedetector": "^7.2.0", + "material-ui-popup-state": "^5.0.10", "react": "^18.2.0", "react-beautiful-dnd": "^13.1.1", + "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", "react-i18next": "^13.5.0", "react-router-dom": "^6.20.0", diff --git a/src/components/MangaCard.tsx b/src/components/MangaCard.tsx index c0c15964..105bc8aa 100644 --- a/src/components/MangaCard.tsx +++ b/src/components/MangaCard.tsx @@ -12,11 +12,16 @@ import Typography from '@mui/material/Typography'; import { Link } from 'react-router-dom'; import { Avatar, Box, CardContent, Stack, styled, Tooltip } from '@mui/material'; import { useTranslation } from 'react-i18next'; +import React from 'react'; +import PopupState, { bindMenu } from 'material-ui-popup-state'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext'; import { SpinnerImage } from '@/components/util/SpinnerImage'; -import { TPartialManga } from '@/typings.ts'; +import { TManga, TPartialManga } from '@/typings.ts'; import { ContinueReadingButton } from '@/components/manga/ContinueReadingButton.tsx'; +import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts'; +import { MangaOptionButton } from '@/components/manga/MangaOptionButton.tsx'; +import { MangaActionMenu } from '@/components/manga/MangaActionMenu.tsx'; const BottomGradient = styled('div')({ position: 'absolute', @@ -65,25 +70,24 @@ interface IProps { manga: TPartialManga; gridLayout?: GridLayout; inLibraryIndicator?: boolean; + selected?: boolean | null; + handleSelection?: SelectableCollectionReturnType['handleSelection']; } export const MangaCard = (props: IProps) => { const { t } = useTranslation(); + const { manga, gridLayout, inLibraryIndicator, selected, handleSelection } = props; const { - manga: { - id, - title, - thumbnailUrl: tmpThumbnailUrl, - downloadCount, - unreadCount: unread, - inLibrary, - lastReadChapter, - chapters, - }, - gridLayout, - inLibraryIndicator, - } = props; + id, + title, + thumbnailUrl: tmpThumbnailUrl, + downloadCount, + unreadCount: unread, + inLibrary, + lastReadChapter, + chapters, + } = manga; const thumbnailUrl = tmpThumbnailUrl ?? 'nonExistingMangaUrl'; const { options: { showContinueReadingButton, showUnreadBadge, showDownloadBadge }, @@ -96,208 +100,294 @@ export const MangaCard = (props: IProps) => { if (gridLayout !== GridLayout.List) { return ( - - - - + {(popupState) => ( + <> + { + if (selected === null) { + return; + } + + e.preventDefault(); + handleSelection?.(id, !selected); }} + to={mangaLinkTo} + style={gridLayout === GridLayout.Comfortable ? { textDecoration: 'none' } : {}} > - theme.palette.primary.main, + backgroundColor: (theme) => (selected ? theme.palette.primary.main : undefined), + '@media (hover: hover) and (pointer: fine)': { + '&:hover .manga-option-button': { + visibility: 'visible', + pointerEvents: 'all', + }, + }, }} > - {inLibraryIndicator && inLibrary && ( - - {t('manga.button.in_library')} - - )} - {showUnreadBadge && (unread ?? 0) > 0 && ( - {unread} - )} - {showDownloadBadge && (downloadCount ?? 0) > 0 && ( - - {downloadCount} - - )} - - - <> - - - - {gridLayout !== GridLayout.Comfortable && ( - - + + + {inLibraryIndicator && inLibrary && ( + + {t('manga.button.in_library')} + + )} + {showUnreadBadge && (unread ?? 0) > 0 && ( + + {unread} + + )} + {showDownloadBadge && (downloadCount ?? 0) > 0 && ( + + {downloadCount} + + )} + + + + + <> + {gridLayout !== GridLayout.Comfortable && ( + <> + + + + )} + - {title} - - - )} + {gridLayout !== GridLayout.Comfortable && ( + + + {title} + + + )} + + + + + + {gridLayout === GridLayout.Comfortable && ( + + + {title} + + + )} + + + {!!handleSelection && popupState.isOpen && ( + ['manga']} + handleSelection={handleSelection} + /> + )} + + )} + + ); + } + + return ( + + {(popupState) => ( + <> + + { + if (selected === null) { + return; + } + + e.preventDefault(); + handleSelection?.(id, !selected); + }} + > + + + + + {title} + + + + + {inLibraryIndicator && inLibrary && ( + + {t('manga.button.in_library')} + + )} + {showUnreadBadge && unread! > 0 && ( + {unread} + )} + {showDownloadBadge && downloadCount! > 0 && ( + + {downloadCount} + + )} + + - + - {gridLayout === GridLayout.Comfortable && ( - - - {title} - - - )} - - - ); - } - - return ( - - - - - - - {title} - - - - - {inLibraryIndicator && inLibrary && ( - - {t('manga.button.in_library')} - - )} - {showUnreadBadge && unread! > 0 && ( - {unread} - )} - {showDownloadBadge && downloadCount! > 0 && ( - - {downloadCount} - - )} - - ['manga']} + handleSelection={handleSelection} /> - - - - + )} + + )} + ); }; diff --git a/src/components/MangaGrid.tsx b/src/components/MangaGrid.tsx index 3cdf922b..84a0133c 100644 --- a/src/components/MangaGrid.tsx +++ b/src/components/MangaGrid.tsx @@ -10,13 +10,15 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 're import Grid, { GridTypeMap } from '@mui/material/Grid'; import { Box, Typography } from '@mui/material'; import { GridItemProps, GridStateSnapshot, VirtuosoGrid } from 'react-virtuoso'; -import { useNavigate, useLocation } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { EmptyView } from '@/components/util/EmptyView'; import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder'; import { MangaCard } from '@/components/MangaCard'; import { GridLayout } from '@/components/context/LibraryOptionsContext'; import { useLocalStorage } from '@/util/useLocalStorage'; -import { TPartialManga } from '@/typings.ts'; +import { TManga, TPartialManga } from '@/typings.ts'; +import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts'; +import { DEFAULT_FULL_FAB_HEIGHT } from '@/components/util/StyledFab.tsx'; const GridContainer = React.forwardRef(({ children, ...props }, ref) => ( @@ -40,8 +42,22 @@ const GridItemContainerWithDimension = ( ); }; -const createMangaCard = (manga: TPartialManga, gridLayout?: GridLayout, inLibraryIndicator?: boolean) => ( - +const createMangaCard = ( + manga: TPartialManga, + gridLayout?: GridLayout, + inLibraryIndicator?: boolean, + isSelectModeActive: boolean = false, + selectedMangaIds?: TManga['id'][], + handleSelection?: DefaultGridProps['handleSelection'], +) => ( + ); type DefaultGridProps = { @@ -50,9 +66,21 @@ type DefaultGridProps = { inLibraryIndicator?: boolean; GridItemContainer: (props: GridTypeMap['props'] & Partial) => JSX.Element; gridLayout?: GridLayout; + isSelectModeActive?: boolean; + selectedMangaIds?: Required[]; + handleSelection?: SelectableCollectionReturnType['handleSelection']; }; -const HorizontalGrid = ({ isLoading, mangas, inLibraryIndicator, GridItemContainer, gridLayout }: DefaultGridProps) => ( +const HorizontalGrid = ({ + isLoading, + mangas, + inLibraryIndicator, + GridItemContainer, + gridLayout, + isSelectModeActive, + selectedMangaIds, + handleSelection, +}: DefaultGridProps) => ( ( - {createMangaCard(manga, gridLayout, inLibraryIndicator)} + {createMangaCard( + manga, + gridLayout, + inLibraryIndicator, + isSelectModeActive, + selectedMangaIds, + handleSelection, + )} )) )} @@ -85,6 +120,9 @@ const VerticalGrid = ({ gridLayout, hasNextPage, loadMore, + isSelectModeActive, + selectedMangaIds, + handleSelection, }: DefaultGridProps & { hasNextPage: boolean; loadMore: () => void; @@ -125,26 +163,38 @@ const VerticalGrid = ({ restoreStateFrom={snapshot} stateChanged={persistGridState} endReached={() => loadMore()} - itemContent={(index) => createMangaCard(mangas[index], gridLayout, inLibraryIndicator)} + itemContent={(index) => + createMangaCard( + mangas[index], + gridLayout, + inLibraryIndicator, + isSelectModeActive, + selectedMangaIds, + handleSelection, + ) + } /> {/* render div to prevent UI jumping around when showing/hiding loading placeholder */ /* eslint-disable-next-line no-nested-ternary */} - {isLoading ? : hasNextPage ?
: null} + {isSelectModeActive && gridLayout === GridLayout.List ? ( + + ) : // eslint-disable-next-line no-nested-ternary + isLoading ? ( + + ) : hasNextPage ? ( +
+ ) : null} ); }; -export interface IMangaGridProps { - mangas: TPartialManga[]; - isLoading: boolean; +export interface IMangaGridProps extends Omit { message?: string; messageExtra?: JSX.Element; hasNextPage: boolean; loadMore: () => void; - gridLayout?: GridLayout; horizontal?: boolean | undefined; noFaces?: boolean | undefined; - inLibraryIndicator?: boolean; } export const MangaGrid: React.FC = (props) => { @@ -159,6 +209,9 @@ export const MangaGrid: React.FC = (props) => { horizontal, noFaces, inLibraryIndicator, + isSelectModeActive, + selectedMangaIds, + handleSelection, } = props; const [dimensions, setDimensions] = useState(document.documentElement.offsetWidth); @@ -221,6 +274,9 @@ export const MangaGrid: React.FC = (props) => { inLibraryIndicator={inLibraryIndicator} GridItemContainer={GridItemContainer} gridLayout={gridLayout} + isSelectModeActive={isSelectModeActive} + selectedMangaIds={selectedMangaIds} + handleSelection={handleSelection} /> ) : ( = (props) => { hasNextPage={hasNextPage} loadMore={loadMore} gridLayout={gridLayout} + isSelectModeActive={isSelectModeActive} + selectedMangaIds={selectedMangaIds} + handleSelection={handleSelection} /> )}
diff --git a/src/components/collection/SelectableCollectionSelectMode.tsx b/src/components/collection/SelectableCollectionSelectMode.tsx new file mode 100644 index 00000000..bf25b098 --- /dev/null +++ b/src/components/collection/SelectableCollectionSelectMode.tsx @@ -0,0 +1,49 @@ +/* + * 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 { Tooltip } from '@mui/material'; +import Checkbox from '@mui/material/Checkbox'; +import { useTranslation } from 'react-i18next'; +import ClearIcon from '@mui/icons-material/Clear'; +import { SelectableCollectionSelectAll } from '@/components/collection/SelectableCollectionSelectAll.tsx'; + +export const SelectableCollectionSelectMode = ({ + isActive, + areAllItemsSelected, + areNoItemsSelected, + onSelectAll, + onModeChange, +}: { + isActive: boolean; + areAllItemsSelected: boolean; + areNoItemsSelected: boolean; + onSelectAll: (selectAll: boolean) => void; + onModeChange: (checked: boolean) => void; +}) => { + const { t } = useTranslation(); + + return ( + <> + {isActive && ( + + )} + + } + sx={{ padding: '8px' }} + checked={isActive} + onChange={(_, checked) => onModeChange(checked)} + /> + + + ); +}; diff --git a/src/components/collection/useSelectableCollection.ts b/src/components/collection/useSelectableCollection.ts index df9ddde1..0bcffb8d 100644 --- a/src/components/collection/useSelectableCollection.ts +++ b/src/components/collection/useSelectableCollection.ts @@ -8,34 +8,92 @@ import { useState } from 'react'; -export const useSelectableCollection = (totalCount: number) => { - const [selectedItemIds, setSelectedItemIds] = useState([]); +export type SelectableCollectionReturnType = { + selectedItemIds: Id[]; + keySelectedItemIds: Id[]; + areAllItemsSelected: boolean; + areNoItemsSelected: boolean; + areAllItemsForKeySelected: boolean; + areNoItemsForKeySelected: boolean; + handleSelection: (id: Id, selected: boolean, key?: Key) => void; + handleSelectAll: (selectAll: boolean, itemIds: Id[], key?: Key) => void; + setSelectionForKey: (key: Key, itemIds: Id[]) => void; + getSelectionForKey: (key: Key) => Id[]; +}; +export const useSelectableCollection = ( + totalCount: number, + { + keyCount = totalCount, + currentKey, + initialState = {} as Record, + }: { + keyCount?: number; + currentKey: Key; + initialState?: Record; + }, +): SelectableCollectionReturnType => { + const [keyToSelectedItemIds, setKeyToSelectedItemIds] = useState>(initialState); + + const selectedItemIds = Object.values(keyToSelectedItemIds).flat(); const areAllItemsSelected = selectedItemIds.length === totalCount; const areNoItemsSelected = !selectedItemIds.length; - const handleSelection = (id: Id, selected: boolean) => { + const keySelectedItemIds = keyToSelectedItemIds[currentKey] ?? []; + const areAllItemsForKeySelected = keySelectedItemIds.length === keyCount; + const areNoItemsForKeySelected = keySelectedItemIds.length === 0; + + const handleSelection = (id: Id, selected: boolean, key: Key = currentKey) => { const deselect = !selected; if (deselect) { - setSelectedItemIds(selectedItemIds.filter((selectedItemId) => selectedItemId !== id)); + setKeyToSelectedItemIds((prevState) => ({ + ...prevState, + [key]: prevState[key].filter((selectedItemId) => selectedItemId !== id), + })); return; } - setSelectedItemIds([...new Set([...selectedItemIds, id])]); + setKeyToSelectedItemIds((prevState) => ({ + ...prevState, + [key]: [...new Set([...(prevState[key] ?? []), id])], + })); }; - const handleSelectAll = (selectAll: boolean, itemIds: Id[]) => { + const handleSelectAll = (selectAll: boolean, itemIds: Id[], key: Key = currentKey) => { switch (selectAll) { case true: - setSelectedItemIds([...itemIds]); + setKeyToSelectedItemIds((prevState) => ({ + ...prevState, + [key]: [...itemIds], + })); break; case false: - setSelectedItemIds([]); + setKeyToSelectedItemIds((prevState) => ({ + ...prevState, + [key]: [], + })); break; default: break; } }; - return { selectedItemIds, handleSelection, handleSelectAll, areAllItemsSelected, areNoItemsSelected }; + const setSelectionForKey = (key: Key, itemIds: Id[]) => { + keyToSelectedItemIds[key] = itemIds; + }; + + const getSelectionForKey = (key: Key) => keyToSelectedItemIds[key]; + + return { + selectedItemIds, + keySelectedItemIds, + handleSelection, + handleSelectAll, + areAllItemsSelected, + areNoItemsSelected, + areAllItemsForKeySelected, + areNoItemsForKeySelected, + setSelectionForKey, + getSelectionForKey, + }; }; diff --git a/src/components/library/LibraryMangaGrid.tsx b/src/components/library/LibraryMangaGrid.tsx index a8d72a9d..e2a14151 100644 --- a/src/components/library/LibraryMangaGrid.tsx +++ b/src/components/library/LibraryMangaGrid.tsx @@ -6,128 +6,35 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect } from 'react'; import { StringParam, useQueryParam } from 'use-query-params'; import { useTranslation } from 'react-i18next'; -import { LibrarySortMode, NullAndUndefined, TManga } from '@/typings'; -import { useSearchSettings } from '@/util/searchSettings'; +import { TManga } from '@/typings'; import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext'; -import { MangaGrid } from '@/components/MangaGrid'; +import { IMangaGridProps, MangaGrid } from '@/components/MangaGrid'; -const unreadFilter = (unread: NullAndUndefined, { unreadCount }: TManga): boolean => { - switch (unread) { - case true: - return !!unreadCount && unreadCount >= 1; - case false: - return unreadCount === 0; - default: - return true; - } -}; - -const downloadedFilter = (downloaded: NullAndUndefined, { downloadCount }: TManga): boolean => { - switch (downloaded) { - case true: - return !!downloadCount && downloadCount >= 1; - case false: - return downloadCount === 0; - default: - return true; - } -}; - -const queryFilter = (query: NullAndUndefined, { title }: TManga): boolean => { - if (!query) return true; - return title.toLowerCase().includes(query.toLowerCase()); -}; - -const queryGenreFilter = (query: NullAndUndefined, { genre }: TManga): boolean => { - if (!query) return true; - const queries = query.split(',').map((str) => str.toLowerCase().trim()); - return queries.every((element) => genre.map((el) => el.toLowerCase()).includes(element)); -}; - -const filterManga = ( - mangas: TManga[], - query: NullAndUndefined, - unread: NullAndUndefined, - downloaded: NullAndUndefined, - ignoreFilters: boolean, -): TManga[] => - mangas.filter((manga) => { - const ignoreFiltersWhileSearching = ignoreFilters && query?.length; - const matchesSearch = queryFilter(query, manga) || queryGenreFilter(query, manga); - const matchesFilters = - ignoreFiltersWhileSearching || (downloadedFilter(downloaded, manga) && unreadFilter(unread, manga)); - - return matchesSearch && matchesFilters; - }); - -const sortByUnread = (a: TManga, b: TManga): number => (a.unreadCount ?? 0) - (b.unreadCount ?? 0); - -const sortByTitle = (a: TManga, b: TManga): number => a.title.localeCompare(b.title); - -const sortByDateAdded = (a: TManga, b: TManga): number => Number(a.inLibraryAt) - Number(b.inLibraryAt); - -const sortByLastRead = (a: TManga, b: TManga): number => - Number(b.lastReadChapter?.lastReadAt ?? 0) - Number(a.lastReadChapter?.lastReadAt ?? 0); - -const sortManga = ( - manga: TManga[], - sort: NullAndUndefined, - desc: NullAndUndefined, -): TManga[] => { - const result = [...manga]; - - switch (sort) { - case 'sortAlph': - result.sort(sortByTitle); - break; - case 'sortDateAdded': - result.sort(sortByDateAdded); - break; - case 'sortToRead': - result.sort(sortByUnread); - break; - case 'sortLastRead': - result.sort(sortByLastRead); - break; - default: - break; - } - - if (desc === true) { - result.reverse(); - } - - return result; -}; - -interface LibraryMangaGridProps { +interface LibraryMangaGridProps + extends Required> { mangas: TManga[]; + showFilteredOutMessage: boolean; isLoading: boolean; message?: string; } -export const LibraryMangaGrid: React.FC = ({ mangas, isLoading, message }) => { +export const LibraryMangaGrid: React.FC = ({ + mangas, + showFilteredOutMessage, + isLoading, + message, + isSelectModeActive, + selectedMangaIds, + handleSelection, +}) => { const { t } = useTranslation(); const [query] = useQueryParam('query', StringParam); const { options } = useLibraryOptionsContext(); const { unread, downloaded } = options; - const { settings } = useSearchSettings(); - - const filteredMangas = useMemo( - () => filterManga(mangas, query, unread, downloaded, settings.ignoreFilters), - [mangas, query, unread, downloaded, settings.ignoreFilters], - ); - const sortedMangas = useMemo( - () => sortManga(filteredMangas, options.sorts, options.sortDesc), - [filteredMangas, options.sorts, options.sortDesc], - ); - - const showFilteredOutMessage = - (unread != null || downloaded != null || query) && filteredMangas.length === 0 && mangas.length > 0; useEffect(() => { window.scrollTo(0, 0); @@ -135,12 +42,15 @@ export const LibraryMangaGrid: React.FC = ({ mangas, isLo return ( undefined} message={showFilteredOutMessage ? t('library.error.label.no_matches') : message} gridLayout={options.gridLayout} + isSelectModeActive={isSelectModeActive} + selectedMangaIds={selectedMangaIds} + handleSelection={handleSelection} /> ); }; diff --git a/src/components/library/useGetVisibleLibraryMangas.ts b/src/components/library/useGetVisibleLibraryMangas.ts new file mode 100644 index 00000000..f31a690e --- /dev/null +++ b/src/components/library/useGetVisibleLibraryMangas.ts @@ -0,0 +1,126 @@ +/* + * 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 { StringParam, useQueryParam } from 'use-query-params'; +import { useMemo } from 'react'; +import { LibrarySortMode, NullAndUndefined, TManga } from '@/typings.ts'; +import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext.tsx'; +import { useSearchSettings } from '@/util/searchSettings.ts'; + +const unreadFilter = (unread: NullAndUndefined, { unreadCount }: TManga): boolean => { + switch (unread) { + case true: + return !!unreadCount && unreadCount >= 1; + case false: + return unreadCount === 0; + default: + return true; + } +}; + +const downloadedFilter = (downloaded: NullAndUndefined, { downloadCount }: TManga): boolean => { + switch (downloaded) { + case true: + return !!downloadCount && downloadCount >= 1; + case false: + return downloadCount === 0; + default: + return true; + } +}; + +const queryFilter = (query: NullAndUndefined, { title }: TManga): boolean => { + if (!query) return true; + return title.toLowerCase().includes(query.toLowerCase()); +}; + +const queryGenreFilter = (query: NullAndUndefined, { genre }: TManga): boolean => { + if (!query) return true; + const queries = query.split(',').map((str) => str.toLowerCase().trim()); + return queries.every((element) => genre.map((el) => el.toLowerCase()).includes(element)); +}; + +const filterManga = ( + mangas: TManga[], + query: NullAndUndefined, + unread: NullAndUndefined, + downloaded: NullAndUndefined, + ignoreFilters: boolean, +): TManga[] => + mangas.filter((manga) => { + const ignoreFiltersWhileSearching = ignoreFilters && query?.length; + const matchesSearch = queryFilter(query, manga) || queryGenreFilter(query, manga); + const matchesFilters = + ignoreFiltersWhileSearching || (downloadedFilter(downloaded, manga) && unreadFilter(unread, manga)); + + return matchesSearch && matchesFilters; + }); + +const sortByUnread = (a: TManga, b: TManga): number => (a.unreadCount ?? 0) - (b.unreadCount ?? 0); + +const sortByTitle = (a: TManga, b: TManga): number => a.title.localeCompare(b.title); + +const sortByDateAdded = (a: TManga, b: TManga): number => Number(a.inLibraryAt) - Number(b.inLibraryAt); + +const sortByLastRead = (a: TManga, b: TManga): number => + Number(b.lastReadChapter?.lastReadAt ?? 0) - Number(a.lastReadChapter?.lastReadAt ?? 0); + +const sortManga = ( + manga: TManga[], + sort: NullAndUndefined, + desc: NullAndUndefined, +): TManga[] => { + const result = [...manga]; + + switch (sort) { + case 'sortAlph': + result.sort(sortByTitle); + break; + case 'sortDateAdded': + result.sort(sortByDateAdded); + break; + case 'sortToRead': + result.sort(sortByUnread); + break; + case 'sortLastRead': + result.sort(sortByLastRead); + break; + default: + break; + } + + if (desc === true) { + result.reverse(); + } + + return result; +}; + +export const useGetVisibleLibraryMangas = (mangas: TManga[]) => { + const [query] = useQueryParam('query', StringParam); + const { options } = useLibraryOptionsContext(); + const { unread, downloaded } = options; + const { settings } = useSearchSettings(); + + const filteredMangas = useMemo( + () => filterManga(mangas, query, unread, downloaded, settings.ignoreFilters), + [mangas, query, unread, downloaded, settings.ignoreFilters], + ); + const sortedMangas = useMemo( + () => sortManga(filteredMangas, options.sorts, options.sortDesc), + [filteredMangas, options.sorts, options.sortDesc], + ); + + const showFilteredOutMessage = + (unread != null || downloaded != null || !!query) && filteredMangas.length === 0 && mangas.length > 0; + + return { + visibleMangas: sortedMangas, + showFilteredOutMessage, + }; +}; diff --git a/src/components/manga/ChapterList.tsx b/src/components/manga/ChapterList.tsx index 28806d05..c7435db7 100644 --- a/src/components/manga/ChapterList.tsx +++ b/src/components/manga/ChapterList.tsx @@ -106,7 +106,7 @@ export const ChapterList: React.FC = ({ manga, isRefreshing }) => { const chapters = useMemo(() => chaptersData?.chapters.nodes ?? [], [chaptersData?.chapters.nodes]); const { areNoItemsSelected, areAllItemsSelected, selectedItemIds, handleSelectAll, handleSelection } = - useSelectableCollection(chapters.length); + useSelectableCollection(chapters.length, { currentKey: 'default' }); const { settings: metadataServerSettings } = useMetadataServerSettings(); diff --git a/src/components/manga/MangaActionMenu.tsx b/src/components/manga/MangaActionMenu.tsx new file mode 100644 index 00000000..3989efb8 --- /dev/null +++ b/src/components/manga/MangaActionMenu.tsx @@ -0,0 +1,127 @@ +/* + * 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 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 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 { bindMenu } from 'material-ui-popup-state'; +import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder'; +import Label from '@mui/icons-material/Label'; +import { useState } from 'react'; +import { TManga } from '@/typings.ts'; +import { MangaAction, MangaDownloadInfo, Mangas, MangaUnreadInfo } from '@/lib/data/Mangas.ts'; +import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts'; +import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts'; +import { CategorySelect } from '@/components/navbar/action/CategorySelect.tsx'; + +export const MangaActionMenu = ({ + manga, + handleSelection, + ...bindMenuProps +}: { + manga: Pick & MangaDownloadInfo & MangaUnreadInfo; + handleSelection?: SelectableCollectionReturnType['handleSelection']; +} & ReturnType) => { + const { t } = useTranslation(); + + const { settings } = useMetadataServerSettings(); + const [isCategorySelectOpen, setIsCategorySelectOpen] = useState(false); + + const isFullyDownloaded = manga.downloadCount === manga.chapters.totalCount; + const hasDownloadedChapters = !!manga.downloadCount; + const hasUnreadChapters = !!manga.unreadCount; + const hasReadChapters = manga.unreadCount !== manga.chapters.totalCount; + + const handleSelect = () => { + handleSelection?.(manga.id, true); + bindMenuProps.onClose(); + }; + + const performAction = (action: MangaAction) => { + Mangas.performAction(action, [manga.id], { + autoDeleteChapters: settings.deleteChaptersManuallyMarkedRead, + }).catch(() => {}); + + bindMenuProps.onClose(); + }; + + return ( + <> + + {!!handleSelection && ( + + + + + {t('chapter.action.label.select')} + + )} + {!isFullyDownloaded && ( + performAction('download')}> + + + + {t('chapter.action.download.add.label.action')} + + )} + {hasDownloadedChapters && ( + performAction('delete')}> + + + + {t('chapter.action.download.delete.label.action')} + + )} + {hasUnreadChapters && ( + performAction('mark_as_read')}> + + + + {t('chapter.action.mark_as_read.add.label.action.current')} + + )} + {hasReadChapters && ( + performAction('mark_as_unread')}> + + + + {t('chapter.action.mark_as_read.remove.label.action')} + + )} + setIsCategorySelectOpen(true)}> + + + {t('manga.action.category.label.action')} + + performAction('remove_from_library')}> + + + + {t('manga.action.library.remove.label.action')} + + + {isCategorySelectOpen && ( + { + setIsCategorySelectOpen(open); + bindMenuProps.onClose(); + }} + mangaId={manga.id} + /> + )} + + ); +}; diff --git a/src/components/manga/MangaOptionButton.tsx b/src/components/manga/MangaOptionButton.tsx new file mode 100644 index 00000000..5499e5ad --- /dev/null +++ b/src/components/manga/MangaOptionButton.tsx @@ -0,0 +1,114 @@ +/* + * 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 { useTranslation } from 'react-i18next'; +import React, { TouchEvent, useMemo } from 'react'; +import { Button, Tooltip } from '@mui/material'; +import Checkbox from '@mui/material/Checkbox'; +import IconButton from '@mui/material/IconButton'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import { PopupState } from 'material-ui-popup-state/es/hooks'; +import { bindTrigger } from 'material-ui-popup-state'; +import { isMobile } from 'react-device-detect'; +import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts'; +import { TManga } from '@/typings.ts'; + +export const MangaOptionButton = ({ + id, + selected, + handleSelection, + asCheckbox = false, + popupState, +}: { + id: number; + selected?: boolean | null; + handleSelection?: SelectableCollectionReturnType['handleSelection']; + asCheckbox?: boolean; + popupState: PopupState; +}) => { + const { t } = useTranslation(); + + const bindTriggerProps = useMemo(() => bindTrigger(popupState), [popupState]); + + const preventDefaultAction = (e: React.BaseSyntheticEvent) => { + e.stopPropagation(); + e.preventDefault(); + }; + + const handleSelectionChange = (e: React.BaseSyntheticEvent, isSelected: boolean) => { + preventDefaultAction(e); + handleSelection?.(id, isSelected); + }; + + const handleClick = (e: React.BaseSyntheticEvent) => { + preventDefaultAction(e); + bindTriggerProps.onClick(e as any); + }; + + const handleTouchStart = (e: React.BaseSyntheticEvent) => { + preventDefaultAction(e); + bindTriggerProps.onTouchStart(e as TouchEvent); + }; + + if (!handleSelection) { + return null; + } + + const isSelected = selected !== null; + if (isSelected) { + if (!asCheckbox) { + return null; + } + + return ( + + + + ); + } + + if (asCheckbox) { + return ( + + + + + + ); + } + + return ( + + + + ); +}; diff --git a/src/components/manga/MangasSelectionFABActionItems.tsx b/src/components/manga/MangasSelectionFABActionItems.tsx new file mode 100644 index 00000000..e88a52d5 --- /dev/null +++ b/src/components/manga/MangasSelectionFABActionItems.tsx @@ -0,0 +1,105 @@ +/* + * 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 Done from '@mui/icons-material/Done'; +import RemoveDone from '@mui/icons-material/RemoveDone'; +import { useTranslation } from 'react-i18next'; +import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder'; +import Label from '@mui/icons-material/Label'; +import { useState } from 'react'; +import { SelectionFABActionItem } from '@/components/manga/SelectionFABActionItem.tsx'; +import { TManga } from '@/typings.ts'; +import { MangaAction, Mangas } from '@/lib/data/Mangas.ts'; +import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts'; +import { CategorySelect } from '@/components/navbar/action/CategorySelect.tsx'; + +const ACTION_DISABLES_SELECTION_MODE: MangaAction[] = ['remove_from_library'] as const; + +export const MangasSelectionFABActionItems = ({ + selectedMangas, + handleClose, +}: { + selectedMangas: TManga[]; + handleClose: (selectionModeState: boolean) => void; +}) => { + const { t } = useTranslation(); + const { settings } = useMetadataServerSettings(); + const [isCategorySelectOpen, setIsCategorySelectOpen] = useState(false); + + const handleAction = (action: MangaAction, mangas: TManga[]) => { + Mangas.performAction(action, Mangas.getIds(mangas), { + autoDeleteChapters: settings.deleteChaptersManuallyMarkedRead, + }).catch(() => {}); + handleClose(!ACTION_DISABLES_SELECTION_MODE.includes(action)); + }; + + return ( + <> + + action="download" + Icon={Download} + matchingItems={[ + ...Mangas.getNotDownloaded(selectedMangas), + ...Mangas.getPartiallyDownloaded(selectedMangas), + ]} + onClick={handleAction} + title={t('chapter.action.download.add.button.selected')} + /> + + action="delete" + Icon={Delete} + matchingItems={[ + ...Mangas.getPartiallyDownloaded(selectedMangas), + ...Mangas.getFullyDownloaded(selectedMangas), + ]} + onClick={handleAction} + title={t('chapter.action.download.delete.button.selected')} + /> + + action="mark_as_read" + Icon={Done} + matchingItems={[...Mangas.getUnread(selectedMangas), ...Mangas.getPartiallyRead(selectedMangas)]} + onClick={handleAction} + title={t('chapter.action.mark_as_read.add.button.selected')} + /> + + action="mark_as_unread" + Icon={RemoveDone} + matchingItems={[...Mangas.getPartiallyRead(selectedMangas), ...Mangas.getFullyRead(selectedMangas)]} + onClick={handleAction} + title={t('chapter.action.mark_as_read.remove.button.selected')} + /> + + action="change_categories" + Icon={Label} + matchingItems={selectedMangas} + onClick={() => setIsCategorySelectOpen(true)} + title={t('manga.action.category.label.action')} + /> + + action="remove_from_library" + Icon={FavoriteBorderIcon} + matchingItems={[...Mangas.getPartiallyRead(selectedMangas), ...Mangas.getFullyRead(selectedMangas)]} + onClick={handleAction} + title={t('manga.action.library.remove.button.selected')} + /> + {isCategorySelectOpen && ( + { + setIsCategorySelectOpen(open); + handleClose(true); + }} + mangaIds={Mangas.getIds(selectedMangas)} + /> + )} + + ); +}; diff --git a/src/components/manga/SelectionFAB.tsx b/src/components/manga/SelectionFAB.tsx index 1cc7f6d1..e2c86844 100644 --- a/src/components/manga/SelectionFAB.tsx +++ b/src/components/manga/SelectionFAB.tsx @@ -7,7 +7,7 @@ */ import MoreHoriz from '@mui/icons-material/MoreHoriz'; -import { Fab, Menu, Box } from '@mui/material'; +import { Fab, Menu, Box, styled } from '@mui/material'; import React, { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { DEFAULT_FAB_STYLE } from '@/components/util/StyledFab'; @@ -19,6 +19,16 @@ interface SelectionFABProps { title: TranslationKey; } +const FabContainer = styled(Box)(({ theme }) => ({ + ...DEFAULT_FAB_STYLE, + height: `calc(${DEFAULT_FAB_STYLE.height} + 1)`, + paddingTop: '8px', + zIndex: 1, // the "Checkbox" (MUI) component of the "ChapterCard" has z-index 1, which causes it to take over the mouse events + [theme.breakpoints.down('md')]: { + marginBottom: '64px', + }, +})); + export const SelectionFAB: React.FC = ({ children, selectedItemsCount, title }) => { const { t } = useTranslation(); @@ -27,15 +37,7 @@ export const SelectionFAB: React.FC = ({ children, selectedIt const handleClose = () => setOpen(false); return ( - + setOpen(true)}> {`${selectedItemsCount} ${t(title, { count: selectedItemsCount })}`} @@ -53,6 +55,6 @@ export const SelectionFAB: React.FC = ({ children, selectedIt > {children(handleClose)} - + ); }; diff --git a/src/components/navbar/action/CategorySelect.tsx b/src/components/navbar/action/CategorySelect.tsx index cabe3541..738044d4 100644 --- a/src/components/navbar/action/CategorySelect.tsx +++ b/src/components/navbar/action/CategorySelect.tsx @@ -6,33 +6,80 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import React, { useMemo } from 'react'; +import { useMemo } from 'react'; import Button from '@mui/material/Button'; import DialogTitle from '@mui/material/DialogTitle'; import DialogContent from '@mui/material/DialogContent'; import DialogActions from '@mui/material/DialogActions'; import Dialog from '@mui/material/Dialog'; -import Checkbox from '@mui/material/Checkbox'; -import FormControlLabel from '@mui/material/FormControlLabel'; import FormGroup from '@mui/material/FormGroup'; import { useTranslation } from 'react-i18next'; import { requestManager } from '@/lib/requests/RequestManager.ts'; +import { Mangas } from '@/lib/data/Mangas.ts'; +import { useSelectableCollection } from '@/components/collection/useSelectableCollection.ts'; +import { ThreeStateCheckboxInput } from '@/components/atoms/ThreeStateCheckboxInput.tsx'; -interface IProps { +type BaseProps = { open: boolean; setOpen: (value: boolean) => void; - mangaId: number; -} +}; -export function CategorySelect(props: IProps) { +type SingleMangaModeProps = { + mangaId: number; +}; + +type MultiMangaModeProps = { + mangaIds: number[]; +}; + +type Props = + | (BaseProps & SingleMangaModeProps & PropertiesNever) + | (BaseProps & PropertiesNever & MultiMangaModeProps); + +const useGetMangaCategoryIds = (mangaId: number | undefined): number[] => { + const { data: mangaResult } = requestManager.useGetManga(mangaId ?? -1, { skip: mangaId === undefined }); + + return useMemo(() => { + if (mangaId === undefined || !mangaResult) { + return []; + } + + return mangaResult.manga.categories.nodes.map((category) => category.id); + }, [mangaResult?.manga.categories.nodes, mangaId]); +}; + +const getCategoryCheckedState = ( + categoryId: number, + categoriesToAdd: number[], + categoriesToRemove: number[], + isSingleSelectionMode: boolean, +): boolean | undefined => { + if (categoriesToAdd.includes(categoryId)) { + return true; + } + + if (isSingleSelectionMode) { + return undefined; + } + + if (categoriesToRemove.includes(categoryId)) { + return false; + } + + return undefined; +}; + +export function CategorySelect(props: Props) { const { t } = useTranslation(); - const { open, setOpen, mangaId } = props; + const { open, setOpen, mangaId, mangaIds: passedMangaIds } = props; - const { data: mangaResult } = requestManager.useGetManga(mangaId); + const isSingleSelectionMode = mangaId !== undefined; + const mangaIds = passedMangaIds ?? [mangaId]; + + const mangaCategories = useGetMangaCategoryIds(mangaId); const { data } = requestManager.useGetCategories(); const categoriesData = data?.categories.nodes; - const [triggerMutate] = requestManager.useUpdateMangaCategories(); const allCategories = useMemo(() => { const cats = [...(categoriesData ?? [])]; // make copy @@ -42,29 +89,45 @@ export function CategorySelect(props: IProps) { return cats; }, [categoriesData]); - const selectedIds = mangaResult?.manga.categories.nodes.map((c) => c.id) ?? []; + const { handleSelection, setSelectionForKey, getSelectionForKey } = useSelectableCollection< + number, + 'categoriesToAdd' | 'categoriesToRemove' + >(allCategories.length, { + currentKey: 'categoriesToAdd', + initialState: { + categoriesToAdd: mangaCategories, + categoriesToRemove: [], + }, + }); + + const categoriesToAdd = getSelectionForKey('categoriesToAdd'); + const categoriesToRemove = getSelectionForKey('categoriesToRemove'); const handleCancel = () => { + setSelectionForKey('categoriesToAdd', mangaCategories); + setSelectionForKey('categoriesToRemove', []); setOpen(false); }; const handleOk = () => { setOpen(false); - }; - const handleChange = (event: React.ChangeEvent, categoryId: number) => { - const { checked } = event.target as HTMLInputElement; + const addToCategories = isSingleSelectionMode + ? categoriesToAdd.filter((categoryId) => !mangaCategories.includes(categoryId)) + : categoriesToAdd; + const removeFromCategories = isSingleSelectionMode + ? mangaCategories.filter((categoryId) => !categoriesToAdd.includes(categoryId)) + : categoriesToRemove; - // TODO - update to only update categories when clicking OK - can now be updated in one go with graphql - triggerMutate({ - variables: { - input: { - id: mangaId, - patch: { - addToCategories: checked ? [categoryId] : [], - removeFromCategories: !checked ? [categoryId] : [], - }, - }, + const isUpdateRequired = !!addToCategories.length || !!removeFromCategories.length; + if (!isUpdateRequired) { + return; + } + + Mangas.performAction('change_categories', mangaIds, { + changeCategoriesPatch: { + addToCategories, + removeFromCategories, }, }); }; @@ -91,14 +154,25 @@ export function CategorySelect(props: IProps) { )} {allCategories.map((category) => ( - handleChange(e, category.id)} - color="default" - /> - } + { + handleSelection(category.id, false, 'categoriesToAdd'); + handleSelection(category.id, false, 'categoriesToRemove'); + + if (checked) { + handleSelection(category.id, true, 'categoriesToAdd'); + } + + if (checked === false) { + handleSelection(category.id, true, 'categoriesToRemove'); + } + }} label={category.name} key={category.id} /> diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 956d8a39..41774564 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -446,6 +446,34 @@ "title": "Library" }, "manga": { + "action": { + "library": { + "remove": { + "button": { + "selected": "Remove selected from the library" + }, + "label": { + "action": "Remove from the library", + "error_one": "Could not remove manga from the library", + "error_other": "Could not remove manga from the library", + "success_one": "Removed manga from the library", + "success_other": "Removed {{count}} manga from the library" + } + } + }, + "category": { + "button": { + "selected": "Change categories of selected" + }, + "label": { + "action": "Change categories", + "error_one": "Could not change the categories of the manga", + "error_other": "Could not change the categories of the manga", + "success_one": "Changed categories of manga", + "success_other": "Changed categories of {{count}} manga" + } + } + }, "button": { "add_to_library": "Add To Library", "in_library": "In Library" @@ -464,7 +492,9 @@ "reload_from_source": "Reload data from source", "status": "Status" }, - "title": "Manga" + "title": "Manga", + "title_one": "Manga", + "title_other": "Manga" }, "reader": { "button": { diff --git a/src/lib/data/Chapters.ts b/src/lib/data/Chapters.ts new file mode 100644 index 00000000..88a44bc5 --- /dev/null +++ b/src/lib/data/Chapters.ts @@ -0,0 +1,36 @@ +/* + * 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'; + +type ChapterDownloadInfo = Pick; +type ChapterBookmarkInfo = Pick; + +export class Chapters { + static getIds(chapters: { id: number }[]): number[] { + return chapters.map((chapter) => chapter.id); + } + + static isDeletable({ isDownloaded }: ChapterDownloadInfo): boolean { + return isDownloaded; + } + + static isAutoDeletable( + { isBookmarked, ...chapter }: ChapterDownloadInfo & ChapterBookmarkInfo, + canDeleteBookmarked: boolean = false, + ): boolean { + return Chapters.isDeletable(chapter) && (!isBookmarked || canDeleteBookmarked); + } + + static getAutoDeletable( + chapters: Chapters[], + canDeleteBookmarked?: boolean, + ): Chapters[] { + return chapters.filter((chapter) => Chapters.isAutoDeletable(chapter, canDeleteBookmarked)); + } +} diff --git a/src/lib/data/Mangas.ts b/src/lib/data/Mangas.ts new file mode 100644 index 00000000..37b5a932 --- /dev/null +++ b/src/lib/data/Mangas.ts @@ -0,0 +1,227 @@ +/* + * 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 { t as translate } from 'i18next'; +import { TManga, TranslationKey } from '@/typings.ts'; +import { requestManager } from '@/lib/requests/RequestManager.ts'; +import { + ChapterConditionInput, + GetMangasChapterIdsWithStateQuery, + UpdateMangaCategoriesPatchInput, +} from '@/lib/graphql/generated/graphql.ts'; +import { Chapters } from '@/lib/data/Chapters.ts'; +import { getMetadataServerSettings } from '@/util/metadataServerSettings.ts'; +import { makeToast } from '@/components/util/Toast.tsx'; + +export type MangaAction = + | 'download' + | 'delete' + | 'mark_as_read' + | 'mark_as_unread' + | 'remove_from_library' + | 'change_categories'; + +const actionToTranslationKey: { + [key in MangaAction]: { + 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', + }, + 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', + }, + remove_from_library: { + success: 'manga.action.library.remove.label.success', + error: 'manga.action.library.remove.label.error', + }, + change_categories: { + success: 'manga.action.category.label.success', + error: 'manga.action.category.label.error', + }, +}; + +export type MangaChapterCountInfo = { chapters: Pick }; +export type MangaDownloadInfo = Pick & MangaChapterCountInfo; +export type MangaUnreadInfo = Pick & MangaChapterCountInfo; +export class Mangas { + static getIds(mangas: { id: number }[]): number[] { + return mangas.map((manga) => manga.id); + } + + static isNotDownloaded({ downloadCount }: MangaDownloadInfo): boolean { + return downloadCount === 0; + } + + static getNotDownloaded(mangas: Mangas[]): Mangas[] { + return mangas.filter(Mangas.isNotDownloaded); + } + + static isFullyDownloaded({ downloadCount, chapters: { totalCount } }: MangaDownloadInfo): boolean { + return downloadCount === totalCount; + } + + static getFullyDownloaded(mangas: Mangas[]): Mangas[] { + return mangas.filter(Mangas.isFullyDownloaded); + } + + static isPartiallyDownloaded(manga: MangaDownloadInfo): boolean { + return !Mangas.isNotDownloaded(manga) && !Mangas.isFullyDownloaded(manga); + } + + static getPartiallyDownloaded(mangas: Mangas[]): Mangas[] { + return mangas.filter(Mangas.isPartiallyDownloaded); + } + + static isUnread({ unreadCount, chapters: { totalCount } }: MangaUnreadInfo): boolean { + return unreadCount === totalCount; + } + + static getUnread(mangas: Mangas[]): Mangas[] { + return mangas.filter(Mangas.isUnread); + } + + static isFullyRead({ unreadCount }: MangaUnreadInfo): boolean { + return unreadCount === 0; + } + + static getFullyRead(mangas: Mangas[]): Mangas[] { + return mangas.filter(Mangas.isFullyRead); + } + + static isPartiallyRead(manga: MangaUnreadInfo): boolean { + return !Mangas.isUnread(manga) && !Mangas.isFullyRead(manga); + } + + static getPartiallyRead(mangas: Mangas[]): Mangas[] { + return mangas.filter(Mangas.isPartiallyRead); + } + + static async getChapterIdsWithState( + mangaIds: number[], + state: Pick, + ): Promise { + const { data } = await requestManager.getMangasChapterIdsWithState(mangaIds, state).response; + return data.chapters.nodes; + } + + static async downloadChapters(mangaIds: number[]): Promise { + const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isDownloaded: false }); + return Mangas.executeAction( + 'download', + chapters.length, + () => requestManager.addChaptersToDownloadQueue(Chapters.getIds(chapters)).response, + ); + } + + static async deleteChapters(mangaIds: number[]): Promise { + const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isDownloaded: true }); + return Mangas.executeAction( + 'delete', + chapters.length, + () => requestManager.deleteDownloadedChapters(Chapters.getIds(chapters)).response, + ); + } + + static async markAsRead(mangaIds: number[], deleteChapters: boolean = false): Promise { + const [chapters, { deleteChaptersWithBookmark }] = await Promise.all([ + Mangas.getChapterIdsWithState(mangaIds, { isRead: false }), + 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 { + const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isRead: true }); + return Mangas.executeAction( + 'mark_as_unread', + chapters.length, + () => requestManager.updateChapters(Chapters.getIds(chapters), { isRead: false }).response, + ); + } + + static async removeFromLibrary(mangaIds: number[]): Promise { + return Mangas.executeAction( + 'remove_from_library', + mangaIds.length, + () => requestManager.updateMangas(mangaIds, { inLibrary: false }).response, + ); + } + + static async changeCategories(mangaIds: number[], patch: UpdateMangaCategoriesPatchInput): Promise { + return Mangas.executeAction( + 'change_categories', + mangaIds.length, + () => requestManager.updateMangasCategories(mangaIds, patch).response, + ); + } + + private static async executeAction( + action: MangaAction, + itemCount: number, + fnToExecute: () => Promise, + ): Promise { + 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: Action, + mangaIds: number[], + { + autoDeleteChapters, + changeCategoriesPatch, + }: Action extends 'mark_as_read' + ? { autoDeleteChapters: boolean; changeCategoriesPatch?: never } + : Action extends 'change_categories' + ? { autoDeleteChapters?: never; changeCategoriesPatch: UpdateMangaCategoriesPatchInput } + : { autoDeleteChapters?: boolean; changeCategoriesPatch?: UpdateMangaCategoriesPatchInput }, + ): Promise { + switch (action) { + case 'download': + return Mangas.downloadChapters(mangaIds); + case 'delete': + return Mangas.deleteChapters(mangaIds); + case 'mark_as_read': + return Mangas.markAsRead(mangaIds, autoDeleteChapters!); + case 'mark_as_unread': + return Mangas.markAsUnread(mangaIds); + case 'remove_from_library': + return Mangas.removeFromLibrary(mangaIds); + case 'change_categories': + return Mangas.changeCategories(mangaIds, changeCategoriesPatch!); + default: + throw new Error(`performMangasAction::performAction: unknown action "${action}"`); + } + } +} diff --git a/src/lib/graphql/generated/graphql.ts b/src/lib/graphql/generated/graphql.ts index 163783c4..e6ea6ad3 100644 --- a/src/lib/graphql/generated/graphql.ts +++ b/src/lib/graphql/generated/graphql.ts @@ -2581,6 +2581,16 @@ export type GetChaptersQueryVariables = Exact<{ export type GetChaptersQuery = { __typename?: 'Query', chapters: { __typename?: 'ChapterNodeList', totalCount: number, nodes: Array<{ __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', unreadCount: number, downloadCount: number, artist?: string | null, author?: string | null, chaptersLastFetchedAt?: any | null, description?: string | null, genre: Array, id: number, inLibrary: boolean, inLibraryAt: any, initialized: boolean, lastFetchedAt?: any | null, realUrl?: string | null, status: MangaStatus, thumbnailUrl?: string | null, title: string, url: string, categories: { __typename?: 'CategoryNodeList', totalCount: number, nodes: Array<{ __typename?: 'CategoryType', default: boolean, id: number, includeInUpdate: IncludeInUpdate, name: string, order: number, meta: Array<{ __typename?: 'CategoryMetaType', key: string, value: string }>, mangas: { __typename?: 'MangaNodeList', totalCount: number } }> }, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, meta: Array<{ __typename?: 'MangaMetaType', key: string, value: string }>, source?: { __typename?: 'SourceType', displayName: string, iconUrl: string, id: any, isConfigurable: boolean, isNsfw: boolean, lang: string, name: string, supportsLatest: boolean } | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> }>, pageInfo: { __typename?: 'PageInfo', endCursor?: any | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: any | null } } }; +export type GetMangasChapterIdsWithStateQueryVariables = Exact<{ + mangaIds: Array | Scalars['Int']['input']; + isDownloaded?: InputMaybe; + isRead?: InputMaybe; + isBookmarked?: InputMaybe; +}>; + + +export type GetMangasChapterIdsWithStateQuery = { __typename?: 'Query', chapters: { __typename?: 'ChapterNodeList', nodes: Array<{ __typename?: 'ChapterType', id: number, isDownloaded: boolean, isRead: boolean, isBookmarked: boolean }> } }; + export type GetDownloadStatusQueryVariables = Exact<{ [key: string]: never; }>; diff --git a/src/lib/graphql/queries/ChapterQuery.ts b/src/lib/graphql/queries/ChapterQuery.ts index 89e88917..3f362b45 100644 --- a/src/lib/graphql/queries/ChapterQuery.ts +++ b/src/lib/graphql/queries/ChapterQuery.ts @@ -55,3 +55,24 @@ export const GET_CHAPTERS = gql` } } `; + +export const GET_MANGAS_CHAPTER_IDS_WITH_STATE = gql` + query GET_MANGAS_CHAPTER_IDS_WITH_STATE( + $mangaIds: [Int!]! + $isDownloaded: Boolean = null + $isRead: Boolean = null + $isBookmarked: Boolean = null + ) { + chapters( + filter: { mangaId: { in: $mangaIds } } + condition: { isDownloaded: $isDownloaded, isRead: $isRead, isBookmarked: $isBookmarked } + ) { + nodes { + id + isDownloaded + isRead + isBookmarked + } + } + } +`; diff --git a/src/lib/requests/RequestManager.ts b/src/lib/requests/RequestManager.ts index 7307ea07..4a8afa25 100644 --- a/src/lib/requests/RequestManager.ts +++ b/src/lib/requests/RequestManager.ts @@ -162,6 +162,14 @@ import { ResetWebuiUpdateStatusMutationVariables, GetDownloadStatusQuery, GetDownloadStatusQueryVariables, + GetMangasChapterIdsWithStateQuery, + GetMangasChapterIdsWithStateQueryVariables, + ChapterConditionInput, + UpdateMangasMutation, + UpdateMangasMutationVariables, + UpdateMangasCategoriesMutation, + UpdateMangasCategoriesMutationVariables, + UpdateMangaCategoriesPatchInput, } from '@/lib/graphql/generated/graphql.ts'; import { GET_GLOBAL_METADATAS } from '@/lib/graphql/queries/GlobalMetadataQuery.ts'; import { SET_GLOBAL_METADATA } from '@/lib/graphql/mutations/GlobalMetadataMutation.ts'; @@ -178,6 +186,8 @@ import { SET_MANGA_METADATA, UPDATE_MANGA, UPDATE_MANGA_CATEGORIES, + UPDATE_MANGAS, + UPDATE_MANGAS_CATEGORIES, } from '@/lib/graphql/mutations/MangaMutation.ts'; import { GET_MANGA, GET_MANGAS } from '@/lib/graphql/queries/MangaQuery.ts'; import { GET_CATEGORIES, GET_CATEGORY_MANGAS } from '@/lib/graphql/queries/CategoryQuery.ts'; @@ -195,7 +205,7 @@ import { START_DOWNLOADER, STOP_DOWNLOADER, } from '@/lib/graphql/mutations/DownloaderMutation.ts'; -import { GET_CHAPTERS } from '@/lib/graphql/queries/ChapterQuery.ts'; +import { GET_CHAPTERS, GET_MANGAS_CHAPTER_IDS_WITH_STATE } from '@/lib/graphql/queries/ChapterQuery.ts'; import { GET_CHAPTER_PAGES_FETCH, GET_MANGA_CHAPTERS_FETCH, @@ -841,6 +851,12 @@ export class RequestManager { } } + public getGlobalMeta( + options?: QueryOptions, + ): AbortabaleApolloQueryResponse { + return this.doRequest(GQLMethod.QUERY, GET_GLOBAL_METADATAS, {}, options); + } + public useGetGlobalMeta( options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { @@ -1337,7 +1353,8 @@ export class RequestManager { const wrappedMutate = (mutateOptions: Parameters[0]) => mutate({ onCompleted: () => { - this.graphQLClient.client.cache.evict({ fieldName: 'mangas' }); + this.graphQLClient.client.cache.evict({ broadcast: true, fieldName: 'categories' }); + this.graphQLClient.client.cache.evict({ broadcast: true, fieldName: 'mangas' }); }, ...mutateOptions, }); @@ -1345,6 +1362,26 @@ export class RequestManager { return [wrappedMutate, result]; } + public updateMangasCategories( + mangaIds: number[], + patch: UpdateMangaCategoriesPatchInput, + options?: MutationOptions, + ): AbortableApolloMutationResponse { + const response = this.doRequest( + GQLMethod.MUTATION, + UPDATE_MANGAS_CATEGORIES, + { input: { ids: mangaIds, patch } }, + options, + ); + + response.response.then(() => { + this.graphQLClient.client.cache.evict({ broadcast: true, fieldName: 'categories' }); + this.graphQLClient.client.cache.evict({ broadcast: true, fieldName: 'mangas' }); + }); + + return response; + } + public updateManga( id: number, patch: UpdateMangaPatchInput, @@ -1364,6 +1401,27 @@ export class RequestManager { return result; } + public updateMangas( + ids: number[], + patch: UpdateMangaPatchInput, + options?: MutationOptions, + ): AbortableApolloMutationResponse { + const result = this.doRequest( + GQLMethod.MUTATION, + UPDATE_MANGAS, + { input: { ids, patch } }, + options, + ); + + result.response.then(() => { + this.graphQLClient.client.cache.evict({ fieldName: 'categories' }); + this.graphQLClient.client.cache.evict({ fieldName: 'category' }); + this.graphQLClient.client.cache.evict({ fieldName: 'mangas' }); + }); + + return result; + } + public setMangaMeta( mangaId: number, key: string, @@ -1401,6 +1459,22 @@ export class RequestManager { ); } + public getMangasChapterIdsWithState( + mangaIds: number[], + states: Pick, + options?: QueryOptions, + ): AbortabaleApolloQueryResponse { + return this.doRequest( + GQLMethod.QUERY, + GET_MANGAS_CHAPTER_IDS_WITH_STATE, + { mangaIds, ...states }, + { + fetchPolicy: 'no-cache', + ...options, + }, + ); + } + public getMangaChaptersFetch( mangaId: number | string, options?: MutationOptions, diff --git a/src/lib/requests/client/GraphQLClient.ts b/src/lib/requests/client/GraphQLClient.ts index 4ef7f1ca..d5b42fd0 100644 --- a/src/lib/requests/client/GraphQLClient.ts +++ b/src/lib/requests/client/GraphQLClient.ts @@ -61,7 +61,6 @@ const typePolicies: StrictTypedTypePolicies = { chapters: { keyArgs: ['condition', 'filter', 'orderBy', 'orderByType'], merge(existing, incoming) { - console.log('merge chapters', { ...existing }, { ...incoming }); if (existing == null) { return incoming; } diff --git a/src/screens/Library.tsx b/src/screens/Library.tsx index 1cf91d46..5b65c55f 100644 --- a/src/screens/Library.tsx +++ b/src/screens/Library.tsx @@ -7,7 +7,7 @@ */ import { Chip, Tab, Tabs, styled, Box } from '@mui/material'; -import React, { useContext, useEffect, useMemo } from 'react'; +import React, { useContext, useEffect, useMemo, useState } from 'react'; import { useQueryParam, NumberParam } from 'use-query-params'; import { useTranslation } from 'react-i18next'; import { requestManager } from '@/lib/requests/RequestManager.ts'; @@ -20,6 +20,13 @@ import { AppbarSearch } from '@/components/util/AppbarSearch'; import { UpdateChecker } from '@/components/library/UpdateChecker'; import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext'; import { NavBarContext } from '@/components/context/NavbarContext.tsx'; +import { useSelectableCollection } from '@/components/collection/useSelectableCollection.ts'; +import { TManga } from '@/typings.ts'; +import { SelectableCollectionSelectMode } from '@/components/collection/SelectableCollectionSelectMode.tsx'; +import { useGetVisibleLibraryMangas } from '@/components/library/useGetVisibleLibraryMangas.ts'; +import { SelectionFAB } from '@/components/manga/SelectionFAB.tsx'; +import { MangasSelectionFABActionItems } from '@/components/manga/MangasSelectionFABActionItems.tsx'; +import { PARTIAL_MANGA_FIELDS } from '@/lib/graphql/Fragments.ts'; const StyledGridWrapper = styled(Box)(({ theme }) => ({ // TabsMenu height + TabsMenu bottom padding - grid item top padding @@ -82,8 +89,36 @@ export function Library() { data: categoryMangaResponse, error: mangaError, loading: mangaLoading, - } = requestManager.useGetCategoryMangas(activeTab?.id, { skip: !activeTab, nextFetchPolicy: 'cache-only' }); - const mangas = categoryMangaResponse?.mangas.nodes ?? []; + } = requestManager.useGetCategoryMangas(activeTab?.id, { skip: !activeTab }); + const categoryMangas = categoryMangaResponse?.mangas.nodes ?? []; + const { visibleMangas: mangas, showFilteredOutMessage } = useGetVisibleLibraryMangas(categoryMangas); + + const [isSelectModeActive, setIsSelectModeActive] = useState(false); + const { + areNoItemsForKeySelected: areNoItemsSelected, + areAllItemsForKeySelected: areAllItemsSelected, + selectedItemIds, + handleSelectAll, + handleSelection, + } = useSelectableCollection(mangas.length, { currentKey: activeTab?.id.toString() }); + + const handleSelect = (id: number, selected: boolean) => { + setIsSelectModeActive(!!(selectedItemIds.length + (selected ? 1 : -1))); + handleSelection(id, selected); + }; + + const selectedMangas = useMemo( + () => + selectedItemIds.map( + (id) => + requestManager.graphQLClient.client.cache.readFragment({ + id: requestManager.graphQLClient.client.cache.identify({ __typename: 'MangaType', id }), + fragment: PARTIAL_MANGA_FIELDS, + fragmentName: 'PARTIAL_MANGA_FIELDS', + })!, + ), + [selectedItemIds.length], + ); const { setTitle, setAction } = useContext(NavBarContext); useEffect(() => { @@ -91,22 +126,53 @@ export function Library() { const navBarTitle = ( {title} - {areCategoriesLoading || !options.showTabSize ? null : } + {options.showTabSize && } ); setTitle(navBarTitle, title); setAction( <> - - - + {!isSelectModeActive && ( + <> + + + + + )} + + handleSelectAll(selectAll, [...new Set(mangas.map((manga) => manga.id))]) + } + onModeChange={(checked) => { + setIsSelectModeActive(checked); + + if (checked) { + handleSelectAll(true, [...new Set(mangas.map((manga) => manga.id))]); + } else { + tabs.forEach((tab) => handleSelectAll(false, [], tab.id.toString())); + } + }} + /> , ); return () => { setTitle(''); setAction(null); }; - }, [t, librarySize, areCategoriesLoading, options]); + }, [ + t, + librarySize, + areCategoriesLoading, + options, + isSelectModeActive, + areNoItemsSelected, + areAllItemsSelected, + selectedItemIds.length, + mangas.length, + ]); const handleTabChange = (newTab: number) => { setTabSearchParam(newTab); @@ -135,6 +201,10 @@ export function Library() { mangas={mangas} message={t('library.error.label.empty')} isLoading={activeTab != null && mangaLoading} + selectedMangaIds={selectedItemIds} + isSelectModeActive={isSelectModeActive} + handleSelection={handleSelect} + showFilteredOutMessage={showFilteredOutMessage} /> ); } @@ -143,49 +213,68 @@ export function Library() { const scrollableTabs = window.innerWidth < tabs.length * 160; return ( - - handleTabChange(newTab)} - indicatorColor="primary" - textColor="primary" - centered={!scrollableTabs} - variant={scrollableTabs ? 'scrollable' : 'fullWidth'} - scrollButtons - allowScrollButtonsMobile - > + <> + + handleTabChange(newTab)} + indicatorColor="primary" + textColor="primary" + centered={!scrollableTabs} + variant={scrollableTabs ? 'scrollable' : 'fullWidth'} + scrollButtons + allowScrollButtonsMobile + > + {tabs.map((tab) => ( + + {tab.name} + {options.showTabSize ? : null} + + } + value={tab.order} + /> + ))} + {tabs.map((tab) => ( - - {tab.name} - {options.showTabSize ? : null} - - } - value={tab.order} - /> + + {tab === activeTab && + (mangaError ? ( + + ) : ( + + ))} + ))} - - {tabs.map((tab) => ( - - {tab === activeTab && - (mangaError ? ( - - ) : ( - - ))} - - ))} - + + {isSelectModeActive && ( + + {(handleClose) => ( + { + handleClose(); + setIsSelectModeActive(selectionModeState); + }} + /> + )} + + )} + ); } diff --git a/src/util/metadataServerSettings.ts b/src/util/metadataServerSettings.ts index 8cf80621..ebd7e109 100644 --- a/src/util/metadataServerSettings.ts +++ b/src/util/metadataServerSettings.ts @@ -32,3 +32,14 @@ export const useMetadataServerSettings = (): { return { metadata, settings, loading }; }; + +export const getMetadataServerSettings = async (): Promise => { + const { data, error } = await requestManager.getGlobalMeta().response; + + if (error) { + throw error; + } + + const metadata = convertFromGqlMeta(data?.metas.nodes); + return getMetadataServerSettingsWithDefaultFallback(metadata); +}; diff --git a/yarn.lock b/yarn.lock index 859e5d32..4d86044f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -487,6 +487,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.20.6", "@babel/runtime@^7.23.5": + version "7.23.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.6.tgz#c05e610dc228855dc92ef1b53d07389ed8ab521d" + integrity sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.18.10", "@babel/template@^7.20.7", "@babel/template@^7.22.15": version "7.22.15" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38" @@ -1400,11 +1407,29 @@ clsx "^2.0.0" prop-types "^15.8.1" +"@mui/base@5.0.0-beta.28": + version "5.0.0-beta.28" + resolved "https://registry.yarnpkg.com/@mui/base/-/base-5.0.0-beta.28.tgz#f072e55c0530f456ee5cb5cde2af788fdda3bf05" + integrity sha512-KIoSc5sUFceeCaZTq5MQBapFzhHqMo4kj+4azWaCAjorduhcRQtN+BCgVHmo+gvEjix74bUfxwTqGifnu2fNTg== + dependencies: + "@babel/runtime" "^7.23.5" + "@floating-ui/react-dom" "^2.0.4" + "@mui/types" "^7.2.11" + "@mui/utils" "^5.15.1" + "@popperjs/core" "^2.11.8" + clsx "^2.0.0" + prop-types "^15.8.1" + "@mui/core-downloads-tracker@^5.14.18": version "5.14.18" resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.14.18.tgz#f8b187dc89756fa5c0b7d15aea537a6f73f0c2d8" integrity sha512-yFpF35fEVDV81nVktu0BE9qn2dD/chs7PsQhlyaV3EnTeZi9RZBuvoEfRym1/jmhJ2tcfeWXiRuHG942mQXJJQ== +"@mui/core-downloads-tracker@^5.15.1": + version "5.15.1" + resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.1.tgz#8aad47e2b198640244f05f6486a927ce362e814e" + integrity sha512-y/nUEsWHyBzaKYp9zLtqJKrLod/zMNEWpMj488FuQY9QTmqBiyUhI2uh7PVaLqLewXRtdmG6JV0b6T5exyuYRw== + "@mui/icons-material@^5.14.18": version "5.14.18" resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-5.14.18.tgz#9e92964cde8c7ba32cf50438a83403dc283f2328" @@ -1412,6 +1437,24 @@ dependencies: "@babel/runtime" "^7.23.2" +"@mui/material@^5.0.0": + version "5.15.1" + resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.15.1.tgz#5fc15c6eb9efe4b62b0c30b13bf5fa042bda71a1" + integrity sha512-WA5DVyvacxDakVyAhNqu/rRT28ppuuUFFw1bLpmRzrCJ4uw/zLTATcd4WB3YbB+7MdZNEGG/SJNWTDLEIyn3xQ== + dependencies: + "@babel/runtime" "^7.23.5" + "@mui/base" "5.0.0-beta.28" + "@mui/core-downloads-tracker" "^5.15.1" + "@mui/system" "^5.15.1" + "@mui/types" "^7.2.11" + "@mui/utils" "^5.15.1" + "@types/react-transition-group" "^4.4.10" + clsx "^2.0.0" + csstype "^3.1.2" + prop-types "^15.8.1" + react-is "^18.2.0" + react-transition-group "^4.4.5" + "@mui/material@^5.14.18": version "5.14.18" resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.14.18.tgz#d0a89be3e27afe90135d542ddbf160b3f34e869c" @@ -1439,6 +1482,15 @@ "@mui/utils" "^5.14.18" prop-types "^15.8.1" +"@mui/private-theming@^5.15.1": + version "5.15.1" + resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-5.15.1.tgz#58fd8da48295e105067fa7361734ee0b166d9cca" + integrity sha512-wTbzuy5KjSvCPE9UVJktWHJ0b/tD5biavY9wvF+OpYDLPpdXK52vc1hTDxSbdkHIFMkJExzrwO9GvpVAHZBnFQ== + dependencies: + "@babel/runtime" "^7.23.5" + "@mui/utils" "^5.15.1" + prop-types "^15.8.1" + "@mui/styled-engine@^5.14.18": version "5.14.18" resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.14.18.tgz#82d427bc975b85cecdbab2fd9353ed6c2df7eae1" @@ -1449,6 +1501,16 @@ csstype "^3.1.2" prop-types "^15.8.1" +"@mui/styled-engine@^5.15.1": + version "5.15.1" + resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.15.1.tgz#00f179e51afe252022bf356f72354968f9c5bf25" + integrity sha512-7WDZTJLqGexWDjqE9oAgjU8ak6hEtUw2yQU7SIYID5kLVO2Nj/Wi/KicbLsXnTsJNvSqePIlUIWTBSXwWJCPZw== + dependencies: + "@babel/runtime" "^7.23.5" + "@emotion/cache" "^11.11.0" + csstype "^3.1.2" + prop-types "^15.8.1" + "@mui/system@^5.14.18": version "5.14.18" resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.14.18.tgz#0f671e8f0a5e8e965b79235d77c50098f54195b5" @@ -1463,6 +1525,25 @@ csstype "^3.1.2" prop-types "^15.8.1" +"@mui/system@^5.15.1": + version "5.15.1" + resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.15.1.tgz#e2a79b5e188ca89a3e58aa4d27e3484edf9e24b0" + integrity sha512-LAnP0ls69rqW9eBgI29phIx/lppv+WDGI7b3EJN7VZIqw0RezA0GD7NRpV12BgEYJABEii6z5Q9B5tg7dsX0Iw== + dependencies: + "@babel/runtime" "^7.23.5" + "@mui/private-theming" "^5.15.1" + "@mui/styled-engine" "^5.15.1" + "@mui/types" "^7.2.11" + "@mui/utils" "^5.15.1" + clsx "^2.0.0" + csstype "^3.1.2" + prop-types "^15.8.1" + +"@mui/types@^7.2.11": + version "7.2.11" + resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.11.tgz#36b99a88f8010dc716128e568dc05681a69dc7ae" + integrity sha512-KWe/QTEsFFlFSH+qRYf3zoFEj3z67s+qAuSnMMg+gFwbxG7P96Hm6g300inQL1Wy///gSRb8juX7Wafvp93m3w== + "@mui/types@^7.2.9": version "7.2.9" resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.9.tgz#730ee83a37af292a5973962f78ce5c95f31213a7" @@ -1478,6 +1559,16 @@ prop-types "^15.8.1" react-is "^18.2.0" +"@mui/utils@^5.15.1": + version "5.15.1" + resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.15.1.tgz#71d69dc8c0f13a1fd6aca20b53ec496636e6b854" + integrity sha512-V1/d0E3Bju5YdB59HJf2G0tnHrFEvWLN+f8hAXp9+JSNy/LC2zKyqUfPPahflR6qsI681P8G9r4mEZte/SrrYA== + dependencies: + "@babel/runtime" "^7.23.5" + "@types/prop-types" "^15.7.11" + prop-types "^15.8.1" + react-is "^18.2.0" + "@mui/x-date-pickers@^6.18.2": version "6.18.2" resolved "https://registry.yarnpkg.com/@mui/x-date-pickers/-/x-date-pickers-6.18.2.tgz#07f76c4a9ba022b8916607d9b3501c39160787c2" @@ -1773,7 +1864,7 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239" integrity sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw== -"@types/prop-types@*", "@types/prop-types@^15.7.10": +"@types/prop-types@*", "@types/prop-types@^15.7.10", "@types/prop-types@^15.7.11": version "15.7.11" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.11.tgz#2596fb352ee96a1379c657734d4b913a613ad563" integrity sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng== @@ -1802,6 +1893,13 @@ hoist-non-react-statics "^3.3.0" redux "^4.0.0" +"@types/react-transition-group@^4.4.10": + version "4.4.10" + resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.10.tgz#6ee71127bdab1f18f11ad8fb3322c6da27c327ac" + integrity sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q== + dependencies: + "@types/react" "*" + "@types/react-transition-group@^4.4.8": version "4.4.9" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.9.tgz#12a1a1b5b8791067198149867b0823fbace31579" @@ -2497,6 +2595,11 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== +classnames@^2.2.6: + version "2.3.2" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" + integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw== + clean-stack@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" @@ -4356,6 +4459,16 @@ map-cache@^0.2.0: resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" integrity sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg== +material-ui-popup-state@^5.0.10: + version "5.0.10" + resolved "https://registry.yarnpkg.com/material-ui-popup-state/-/material-ui-popup-state-5.0.10.tgz#1c2d42cbe9f04f60fa6e4cd8ceb8ff5a456dfc62" + integrity sha512-gd0DI8skwCSdth/j/yndoIwNkS2eDusosTe5hyPZ3jbrMzDkbQBs+tBbwapQ9hLfgiVLwICd1mwyerUV9Y5Elw== + dependencies: + "@babel/runtime" "^7.20.6" + "@mui/material" "^5.0.0" + classnames "^2.2.6" + prop-types "^15.7.2" + memoize-one@^5.1.1: version "5.2.1" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" @@ -4877,6 +4990,13 @@ react-beautiful-dnd@^13.1.1: redux "^4.0.4" use-memo-one "^1.1.1" +react-device-detect@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/react-device-detect/-/react-device-detect-2.2.3.tgz#97a7ae767cdd004e7c3578260f48cf70c036e7ca" + integrity sha512-buYY3qrCnQVlIFHrC5UcUoAj7iANs/+srdkwsnNjI7anr3Tt7UY6MqNxtMLlr0tMBied0O49UZVK8XKs3ZIiPw== + dependencies: + ua-parser-js "^1.0.33" + react-dom@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" @@ -5641,7 +5761,7 @@ typescript@^5.3.2: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.2.tgz#00d1c7c1c46928c5845c1ee8d0cc2791031d4c43" integrity sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ== -ua-parser-js@^1.0.35: +ua-parser-js@^1.0.33, ua-parser-js@^1.0.35: version "1.0.37" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.37.tgz#b5dc7b163a5c1f0c510b08446aed4da92c46373f" integrity sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==