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==