/* * 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 Chip, { ChipProps } from '@mui/material/Chip'; import Tab from '@mui/material/Tab'; import { styled } from '@mui/material/styles'; import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; import { useQueryParam, NumberParam } from 'use-query-params'; import { useTranslation } from 'react-i18next'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { EmptyViewAbsoluteCentered } from '@/modules/core/components/feedback/EmptyViewAbsoluteCentered.tsx'; import { LoadingPlaceholder } from '@/modules/core/components/feedback/LoadingPlaceholder.tsx'; import { TabPanel } from '@/modules/core/components/tabs/TabPanel.tsx'; import { LibraryToolbarMenu } from '@/modules/library/components/LibraryToolbarMenu.tsx'; import { LibraryMangaGrid } from '@/modules/library/components/LibraryMangaGrid.tsx'; import { AppbarSearch } from '@/modules/core/components/AppbarSearch.tsx'; import { UpdateChecker } from '@/modules/core/components/UpdateChecker.tsx'; import { useSelectableCollection } from '@/modules/collection/hooks/useSelectableCollection.ts'; import { SelectableCollectionSelectMode } from '@/modules/collection/components/SelectableCollectionSelectMode.tsx'; import { useGetVisibleLibraryMangas } from '@/modules/library/hooks/useGetVisibleLibraryMangas.ts'; import { SelectionFAB } from '@/modules/collection/components/SelectionFAB.tsx'; import { MangaActionMenuItems } from '@/modules/manga/components/MangaActionMenuItems.tsx'; import { TabsMenu } from '@/modules/core/components/tabs/TabsMenu.tsx'; import { TabsWrapper } from '@/modules/core/components/tabs/TabsWrapper.tsx'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { GetCategoriesLibraryQuery, GetCategoriesLibraryQueryVariables, GetLibraryMangaCountQuery, GetLibraryMangaCountQueryVariables, MangaChapterStatFieldsFragment, MangaType, } from '@/lib/graphql/generated/graphql.ts'; import { GET_CATEGORIES_LIBRARY } from '@/lib/graphql/queries/CategoryQuery.ts'; import { Mangas } from '@/modules/manga/services/Mangas.ts'; import { MANGA_CHAPTER_STAT_FIELDS } from '@/lib/graphql/fragments/MangaFragments.ts'; import { useLibraryOptionsContext } from '@/modules/library/contexts/LibraryOptionsContext.tsx'; import { useMetadataServerSettings } from '@/modules/settings/services/ServerSettingsMetadata.ts'; import { getCategoryMetadata } from '@/modules/category/services/CategoryMetadata.ts'; import { GET_LIBRARY_MANGA_COUNT } from '@/lib/graphql/queries/MangaQuery.ts'; import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx'; import { useAppTitle } from '@/modules/navigation-bar/hooks/useAppTitle.ts'; const TitleWithSizeTag = styled('span')({ display: 'flex', alignItems: 'center', }); const TitleSizeTag = ({ sx, ...props }: ChipProps) => ( ); export function Library() { const { t } = useTranslation(); const { settings: { showTabSize }, } = useMetadataServerSettings(); const { data: categoriesResponse, error: tabsError, loading: areCategoriesLoading, refetch: refetchCategories, } = requestManager.useGetCategories( GET_CATEGORIES_LIBRARY, { notifyOnNetworkStatusChange: true, }, ); const tabsData = categoriesResponse?.categories.nodes.filter( (category) => category.id !== 0 || (category.id === 0 && category.mangas.totalCount), ); const tabs = tabsData ?? []; const librarySizeResponse = requestManager.useGetMangas< GetLibraryMangaCountQuery, GetLibraryMangaCountQueryVariables >(GET_LIBRARY_MANGA_COUNT, {}); const librarySize = librarySizeResponse.data?.mangas.totalCount ?? 0; const [tabSearchParam, setTabSearchParam] = useQueryParam('tab', NumberParam); const { setOptions } = useLibraryOptionsContext(); const activeTab: (typeof tabs)[number] | undefined = tabs.find((tab) => tab.id === tabSearchParam) ?? tabs[0]; useLayoutEffect(() => { setOptions(getCategoryMetadata(activeTab ?? { id: -1 })); }, [activeTab]); const { data: categoryMangaResponse, error: mangaError, loading: mangaLoading, refetch: refetchCategoryMangas, } = requestManager.useGetCategoryMangas(activeTab?.id, { skip: !activeTab, notifyOnNetworkStatusChange: true }); const categoryMangas = categoryMangaResponse?.mangas.nodes ?? []; const { visibleMangas: mangas, showFilteredOutMessage } = useGetVisibleLibraryMangas(categoryMangas, activeTab); const retryFetchCategoryMangas = useCallback( () => refetchCategoryMangas().catch(defaultPromiseErrorHandler('Library::refetchCategoryMangas')), [refetchCategoryMangas, activeTab], ); const mangaIds = useMemo(() => mangas.map((manga) => manga.id), [mangas]); const [isSelectModeActive, setIsSelectModeActive] = useState(false); const { areNoItemsForKeySelected: areNoItemsSelected, areAllItemsForKeySelected: areAllItemsSelected, selectedItemIds, handleSelectAll, handleSelection, clearSelection, } = useSelectableCollection(mangas.length, { itemIds: mangaIds, currentKey: activeTab?.id.toString(), }); const handleSelect: typeof handleSelection = useCallback( (id, selected, selectOptions) => { setIsSelectModeActive(!!(selectedItemIds.length + (selected ? 1 : -1))); handleSelection(id, selected, selectOptions); }, [setIsSelectModeActive, handleSelection], ); const selectedMangas = useMemo( () => selectedItemIds .map((id) => Mangas.getFromCache( id, MANGA_CHAPTER_STAT_FIELDS, 'MANGA_CHAPTER_STAT_FIELDS', ), ) .filter((manga) => !!manga), [selectedItemIds.length, mangas], ); const selectionFab = useMemo(() => { if (!isSelectModeActive) { return null; } return ( {(handleClose, setHideMenu) => ( { handleClose(); setIsSelectModeActive(false); clearSelection(); }} setHideMenu={setHideMenu} /> )} ); }, [isSelectModeActive, selectedMangas]); const { setAction } = useNavBarContext(); useAppTitle( {t('library.title')} {showTabSize && } , t('library.title'), [t, showTabSize, librarySize], ); useLayoutEffect(() => { setAction( <> {!isSelectModeActive && activeTab && ( <> )} 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 () => { setAction(null); }; }, [ t, librarySize, areCategoriesLoading, isSelectModeActive, areNoItemsSelected, areAllItemsSelected, mangas.length, activeTab, showTabSize, ]); const handleTabChange = (newTab: number) => { setTabSearchParam(newTab); }; if (tabsError != null || librarySizeResponse.error) { return ( { if (tabsError) { refetchCategories().catch(defaultPromiseErrorHandler('Library::refetchCategories')); } if (librarySizeResponse.error) { librarySizeResponse.refetch().catch(defaultPromiseErrorHandler('Library::refetchLibrarySize')); } }} /> ); } if (areCategoriesLoading || librarySizeResponse.loading) { return ; } if (tabs.length === 0) { return ; } if (tabs.length === 1) { return ( <> {selectionFab} ); } return ( handleTabChange(newTab)}> {tabs.map((tab) => ( {tab.name} {showTabSize ? : null} } value={tab.id} /> ))} {tabs.map((tab) => ( {tab === activeTab && ( )} ))} {selectionFab} ); }