Files
suwayomi-material-you-webui/src/modules/source/screens/SourceMangas.tsx

508 lines
19 KiB
TypeScript
Raw Normal View History

/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
2021-01-26 23:32:12 +03:30
* 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/.
*/
2021-01-26 23:32:12 +03:30
import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import IconButton from '@mui/material/IconButton';
import SettingsIcon from '@mui/icons-material/Settings';
import { useQueryParam, StringParam } from 'use-query-params';
import { useTranslation } from 'react-i18next';
import Link from '@mui/material/Link';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Tooltip from '@mui/material/Tooltip';
import { styled } from '@mui/material/styles';
import FavoriteIcon from '@mui/icons-material/Favorite';
import NewReleasesIcon from '@mui/icons-material/NewReleases';
import FilterListIcon from '@mui/icons-material/FilterList';
import {
requestManager,
AbortableApolloUseMutationPaginatedResponse,
SPECIAL_ED_SOURCES,
} from '@/lib/requests/RequestManager.ts';
2024-10-05 21:22:12 +02:00
import { SourceGridLayout } from '@/modules/source/components/SourceGridLayout.tsx';
2024-10-05 16:09:34 +02:00
import { AppbarSearch } from '@/modules/core/components/AppbarSearch.tsx';
2024-10-05 21:22:12 +02:00
import { SourceOptions } from '@/modules/source/components/SourceOptions.tsx';
import { BaseMangaGrid } from '@/modules/manga/components/BaseMangaGrid.tsx';
import {
GetSourceBrowseQuery,
GetSourceBrowseQueryVariables,
GetSourceMangasFetchMutation,
GetSourceMangasFetchMutationVariables,
SourceType,
} from '@/lib/graphql/generated/graphql.ts';
import { NavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx';
2024-10-05 22:53:22 +02:00
import { useMetadataServerSettings } from '@/modules/settings/services/ServerSettingsMetadata.ts';
2024-10-05 16:09:34 +02:00
import { useLocalStorage, useSessionStorage } from '@/modules/core/hooks/useStorage.tsx';
2024-10-05 23:22:42 +02:00
import { AppStorage } from '@/lib/storage/AppStorage.ts';
2024-10-05 19:21:26 +02:00
import { getGridSnapshotKey } from '@/modules/manga/components/MangaGrid.tsx';
import { createUpdateSourceMetadata, useGetSourceMetadata } from '@/modules/source/services/SourceMetadata.ts';
2024-10-05 23:22:42 +02:00
import { makeToast } from '@/modules/core/utils/Toast.ts';
import { GET_SOURCE_BROWSE } from '@/lib/graphql/queries/SourceQuery.ts';
2024-10-05 16:09:34 +02:00
import { TranslationKey } from '@/Base.types.ts';
2024-10-05 21:22:12 +02:00
import { IPos } from '@/modules/source/Source.types.ts';
2024-10-06 16:15:01 +02:00
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { EmptyView } from '@/modules/core/components/placeholder/EmptyView.tsx';
import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx';
import { MangaIdInfo } from '@/modules/manga/Manga.types.ts';
import { GridLayout } from '@/modules/core/Core.types.ts';
2024-12-11 20:10:59 +01:00
import { AppRoutes } from '@/modules/core/AppRoute.constants.ts';
2024-12-20 17:24:40 +01:00
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
const DEFAULT_SOURCE: Pick<SourceType, 'id'> = { id: '-1' };
const ContentTypeMenu = styled('div')(({ theme }) => ({
display: 'flex',
2024-09-14 03:01:07 +02:00
position: 'sticky',
width: '100%',
zIndex: 1,
2024-09-14 03:01:07 +02:00
padding: theme.spacing(1),
gap: theme.spacing(1),
backgroundColor: theme.palette.background.default,
}));
2024-09-14 03:01:07 +02:00
const ContentTypeButton = styled(Button)(() => ({}));
2024-09-14 03:01:07 +02:00
const StyledGridWrapper = styled(Box)(() => ({
minHeight: '100%',
position: 'relative',
}));
export enum SourceContentType {
POPULAR,
LATEST,
SEARCH,
}
const SOURCE_CONTENT_TYPE_TO_ERROR_MSG_KEY: { [contentType in SourceContentType]: TranslationKey } = {
[SourceContentType.POPULAR]: 'manga.error.label.no_mangas_found',
[SourceContentType.LATEST]: 'manga.error.label.no_mangas_found',
[SourceContentType.SEARCH]: 'manga.error.label.no_matches',
};
2024-07-08 18:02:50 +02:00
const getUniqueMangas = <Manga extends MangaIdInfo>(mangas: Manga[]): Manga[] => {
const mangaIdToManga: Record<string, Manga> = {};
const uniqueMangas: Manga[] = [];
mangas.forEach((manga) => {
const isDuplicate = !!mangaIdToManga[manga.id];
if (!isDuplicate) {
mangaIdToManga[manga.id] = manga;
uniqueMangas.push(manga);
}
});
return uniqueMangas;
};
const useSourceManga = (
sourceId: string,
contentType: SourceContentType,
searchTerm: string | null | undefined,
filters: IPos[],
initialPages: number,
hideLibraryEntries: boolean,
): [
AbortableApolloUseMutationPaginatedResponse<GetSourceMangasFetchMutation, GetSourceMangasFetchMutationVariables>[0],
AbortableApolloUseMutationPaginatedResponse<
GetSourceMangasFetchMutation,
GetSourceMangasFetchMutationVariables
>[1][number] & { filteredOutAllItemsOfFetchedPage: boolean },
] => {
let result: AbortableApolloUseMutationPaginatedResponse<
GetSourceMangasFetchMutation,
GetSourceMangasFetchMutationVariables
>;
switch (contentType) {
case SourceContentType.POPULAR:
Feature/virtualize manga grid (#363) * Disable ssr by default for "useMediaQuery" SSR would cause the hook to run once with default values and only after the first render with real values. This can cause issue on rendering when depending on useMediaQuery results like in 8c129f2e08e8c807b57c7eef802556faa2edcf1b * Virtualize manga grids * Remove SourceMangas load more guard Was required due to the previous load more trigger logic from the MangaGrid. Due to using Virtuoso now, load more will be triggered only once when the bottom of the grid was reached and thus won't trigger multiple load more requests * Remove old Library pagination Pagination is handled by virtuoso, thus, the previous pagination can be removed * Increase initial loaded pages to make infinite load work virtuoso requires enough initial items to be rendered, so that actual virtualization takes effect, for it to fire "endReached" * Prevent virtuoso grid scrollbar from jumping around In case the items have a big height difference there is a bug where the scrollbar starts jumping around the moment these new items are rendered * Restore scroll position For some reason the UI jumps around when accessing "document.documentElement" during loading more items. After the items are rendered the loading placeholder gets removed and the previous items jump to the bottom of the viewport. * Limit manga grid titles to two lines * Remove "last page" info from MangaGrid Info is not needed. The only thing the MangaGrid has to do is to trigger a request to load more data
2023-06-17 16:50:17 +02:00
result = requestManager.useGetSourcePopularMangas(sourceId, initialPages);
break;
case SourceContentType.LATEST:
Feature/virtualize manga grid (#363) * Disable ssr by default for "useMediaQuery" SSR would cause the hook to run once with default values and only after the first render with real values. This can cause issue on rendering when depending on useMediaQuery results like in 8c129f2e08e8c807b57c7eef802556faa2edcf1b * Virtualize manga grids * Remove SourceMangas load more guard Was required due to the previous load more trigger logic from the MangaGrid. Due to using Virtuoso now, load more will be triggered only once when the bottom of the grid was reached and thus won't trigger multiple load more requests * Remove old Library pagination Pagination is handled by virtuoso, thus, the previous pagination can be removed * Increase initial loaded pages to make infinite load work virtuoso requires enough initial items to be rendered, so that actual virtualization takes effect, for it to fire "endReached" * Prevent virtuoso grid scrollbar from jumping around In case the items have a big height difference there is a bug where the scrollbar starts jumping around the moment these new items are rendered * Restore scroll position For some reason the UI jumps around when accessing "document.documentElement" during loading more items. After the items are rendered the loading placeholder gets removed and the previous items jump to the bottom of the viewport. * Limit manga grid titles to two lines * Remove "last page" info from MangaGrid Info is not needed. The only thing the MangaGrid has to do is to trigger a request to load more data
2023-06-17 16:50:17 +02:00
result = requestManager.useGetSourceLatestMangas(sourceId, initialPages);
break;
case SourceContentType.SEARCH:
result = requestManager.useSourceSearch(
sourceId,
searchTerm ?? '',
filters.map((filter) => {
const { position, state, group } = filter;
const isPartOfGroup = group !== undefined;
if (isPartOfGroup) {
return {
position: group,
groupChange: {
position,
[filter.type]: state,
},
};
}
return {
position,
[filter.type]: state,
};
}),
initialPages,
);
break;
default:
throw new Error(`Unknown ContentType "${contentType}"`);
}
const pages = result[1]!;
const lastLoadedPageIndex = pages.findLastIndex((page) => !!page.data?.fetchSourceManga);
const lastLoadedPage = pages[lastLoadedPageIndex];
const isPageLoading = pages.slice(-1)[0].isLoading;
let filteredOutAllItemsOfFetchedPage = !isPageLoading;
const items = useMemo(() => {
type FetchItemsResult = NonNullable<GetSourceMangasFetchMutation['fetchSourceManga']>['mangas'];
let allItems: FetchItemsResult = [];
pages.forEach((page, index) => {
2024-07-11 17:55:00 +02:00
const pageItems = page.data?.fetchSourceManga?.mangas ?? [];
const nonLibraryPageItems = pageItems.filter((item) => !hideLibraryEntries || !item.inLibrary);
const uniqueItems = getUniqueMangas([...allItems, ...nonLibraryPageItems]);
const isLastPage = !isPageLoading && pages.length === index + 1;
filteredOutAllItemsOfFetchedPage = isLastPage && !nonLibraryPageItems.length && !!pageItems.length;
allItems = uniqueItems;
});
return allItems;
}, [pages, hideLibraryEntries]);
if (lastLoadedPageIndex === -1) {
return [result[0], { ...result[1][result[1].length - 1], filteredOutAllItemsOfFetchedPage }];
}
return [
result[0],
{
...pages[pages.length - 1],
data: {
...lastLoadedPage!.data,
fetchSourceManga: {
...lastLoadedPage!.data!.fetchSourceManga,
hasNextPage:
pages.length > lastLoadedPageIndex + 1
? false
: !!lastLoadedPage!.data!.fetchSourceManga?.hasNextPage,
mangas: items,
},
},
filteredOutAllItemsOfFetchedPage,
},
];
};
2023-10-28 00:32:02 +02:00
export function SourceMangas() {
const { t } = useTranslation();
2024-09-14 03:01:07 +02:00
const { setTitle, setAction, appBarHeight } = useContext(NavBarContext);
const { sourceId } = useParams<{ sourceId: string }>();
const navigate = useNavigate();
const location = useLocation();
const { key: locationKey, state: locationState } = location;
const { contentType: initialContentType = SourceContentType.POPULAR, clearCache = false } =
useLocation<{
contentType: SourceContentType;
clearCache: boolean;
}>().state ?? {};
const {
settings: { hideLibraryEntries },
} = useMetadataServerSettings();
2024-09-17 03:40:55 +02:00
const [sourceGridLayout] = useLocalStorage('source-grid-layout', GridLayout.Compact);
const [query] = useQueryParam('query', StringParam);
const [currentFiltersToApply, setCurrentFiltersToApply] = useSessionStorage<IPos[] | undefined>(
`source-mangas-${sourceId}-filters`,
[],
);
const [filtersToApply, setLocationFiltersToApply] = useSessionStorage<IPos[]>(
`source-mangas-location-${locationKey}-${sourceId}-filters`,
currentFiltersToApply ?? [],
);
const [dialogFiltersToApply, setDialogFiltersToApply] = useState<IPos[]>(filtersToApply);
const [currentContentType, setCurrentContentType] = useSessionStorage<SourceContentType | undefined>(
`source-mangas-${sourceId}-content-type`,
initialContentType,
);
const [contentType, setLocationContentType] = useSessionStorage(
`source-mangas-location-${locationKey}-${sourceId}-content-type`,
query ? SourceContentType.SEARCH : currentContentType!,
);
const scrollToTop = useCallback(() => {
AppStorage.session.setItem(getGridSnapshotKey(location), undefined, false);
window.scrollTo(0, 0);
}, [locationKey]);
const currentQuery = useRef(query);
const currentAbortRequest = useRef<(reason: any) => void>(() => {});
const didSearchChange = currentQuery.current !== query;
if (didSearchChange && contentType === SourceContentType.SEARCH) {
currentQuery.current = query;
currentAbortRequest.current(new Error(`SourceMangas(${sourceId}): search string changed`));
scrollToTop();
}
useEffect(
() => () => {
setCurrentFiltersToApply(undefined);
setCurrentContentType(undefined);
},
[sourceId],
);
const setFiltersToApply = (filters: IPos[]) => {
setCurrentFiltersToApply(filters);
setLocationFiltersToApply(filters);
scrollToTop();
};
const setContentType = (newContentType: SourceContentType) => {
setCurrentContentType(newContentType);
setLocationContentType(newContentType);
};
2024-10-06 16:15:01 +02:00
const [
loadPage,
{ data, error, isLoading: loading, size: lastPageNum, abortRequest, filteredOutAllItemsOfFetchedPage },
] = useSourceManga(sourceId, contentType, query, filtersToApply, 1, hideLibraryEntries);
currentAbortRequest.current = abortRequest;
const isLoading = loading || filteredOutAllItemsOfFetchedPage;
const mangas = data?.fetchSourceManga?.mangas ?? [];
const hasNextPage = !!data?.fetchSourceManga?.hasNextPage;
const { data: sourceData } = requestManager.useGetSource<GetSourceBrowseQuery, GetSourceBrowseQueryVariables>(
GET_SOURCE_BROWSE,
sourceId,
);
const source = sourceData?.source;
const filters = source?.filters ?? [];
const { savedSearches = {} } = useGetSourceMetadata(source ?? DEFAULT_SOURCE);
2024-12-20 17:24:40 +01:00
const updateSourceMetadata = createUpdateSourceMetadata<'savedSearches'>(source ?? { id: '-1' }, (e) =>
makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)),
);
const selectSavedSearch = useCallback(
(savedSearch: string) => {
const { query: savedSearchQuery, filters: savedSearchFilters } = savedSearches[savedSearch];
if (savedSearchFilters) {
setDialogFiltersToApply(savedSearchFilters);
setFiltersToApply(savedSearchFilters);
}
navigate(
{
pathname: '',
search: savedSearchQuery ? `query=${savedSearchQuery}` : undefined,
},
{ state: { ...locationState, contentType: SourceContentType.SEARCH } },
);
},
[savedSearches, locationState],
);
const handleSavedSearchesUpdate = useCallback(
(savedSearch: string, updateType: 'create' | 'delete') => {
if (updateType === 'delete') {
const savedSearchesCopy = { ...savedSearches };
delete savedSearchesCopy[savedSearch];
updateSourceMetadata('savedSearches', savedSearchesCopy);
return;
}
const updatedSavedSearches = {
...savedSearches,
[savedSearch]: { query: query ?? undefined, filters: filtersToApply },
};
updateSourceMetadata('savedSearches', updatedSavedSearches);
},
[savedSearches, query, filtersToApply],
);
const message = !isLoading ? t(SOURCE_CONTENT_TYPE_TO_ERROR_MSG_KEY[contentType]) : undefined;
const isLocalSource = sourceId === '0';
const messageExtra = isLocalSource ? (
<>
<span>{t('source.local_source.label.checkout')} </span>
2024-05-04 23:54:33 +02:00
<Link href="https://github.com/Suwayomi/Suwayomi-Server/wiki/Local-Source" target="_blank" rel="noreferrer">
{t('source.local_source.label.guide')}
</Link>
</>
) : undefined;
2022-03-06 21:02:06 +00:00
const updateContentType = useCallback(
(newContentType: SourceContentType, newSearch?: string | null) => {
setContentType(newContentType);
scrollToTop();
if (query && !newSearch) {
navigate(
{
pathname: '',
},
{
state: { ...locationState, contentType: newContentType },
},
);
}
},
[setContentType, query, scrollToTop],
);
const setSearchContentType = !!query && contentType !== SourceContentType.SEARCH;
if (setSearchContentType) {
updateContentType(SourceContentType.SEARCH, query);
}
2021-09-13 19:18:21 +04:30
Feature/virtualize manga grid (#363) * Disable ssr by default for "useMediaQuery" SSR would cause the hook to run once with default values and only after the first render with real values. This can cause issue on rendering when depending on useMediaQuery results like in 8c129f2e08e8c807b57c7eef802556faa2edcf1b * Virtualize manga grids * Remove SourceMangas load more guard Was required due to the previous load more trigger logic from the MangaGrid. Due to using Virtuoso now, load more will be triggered only once when the bottom of the grid was reached and thus won't trigger multiple load more requests * Remove old Library pagination Pagination is handled by virtuoso, thus, the previous pagination can be removed * Increase initial loaded pages to make infinite load work virtuoso requires enough initial items to be rendered, so that actual virtualization takes effect, for it to fire "endReached" * Prevent virtuoso grid scrollbar from jumping around In case the items have a big height difference there is a bug where the scrollbar starts jumping around the moment these new items are rendered * Restore scroll position For some reason the UI jumps around when accessing "document.documentElement" during loading more items. After the items are rendered the loading placeholder gets removed and the previous items jump to the bottom of the viewport. * Limit manga grid titles to two lines * Remove "last page" info from MangaGrid Info is not needed. The only thing the MangaGrid has to do is to trigger a request to load more data
2023-06-17 16:50:17 +02:00
const loadMore = useCallback(() => {
if (!hasNextPage) {
return;
}
loadPage(lastPageNum + 1);
}, [lastPageNum, hasNextPage, contentType]);
const resetFilters = () => {
setDialogFiltersToApply([]);
setFiltersToApply([]);
};
useEffect(() => {
if (filteredOutAllItemsOfFetchedPage && hasNextPage && !loading) {
loadPage(lastPageNum + 1);
}
}, [filteredOutAllItemsOfFetchedPage, loading]);
useEffect(() => {
if (!clearCache) {
return;
}
const requiresClear = SPECIAL_ED_SOURCES.REVALIDATION.includes(sourceId);
if (!requiresClear) {
return;
}
requestManager.clearBrowseCacheFor(sourceId);
}, [clearCache]);
useLayoutEffect(() => {
2024-07-01 19:51:27 +02:00
setTitle(source?.displayName ?? t('source.title_one'));
setAction(
<>
<AppbarSearch />
<SourceGridLayout />
{source?.isConfigurable && (
2023-11-05 17:22:29 +01:00
<Tooltip title={t('settings.title')}>
<IconButton
2024-12-11 20:10:59 +01:00
onClick={() => navigate(AppRoutes.sources.childRoutes.configure.path(sourceId))}
2023-11-05 17:22:29 +01:00
aria-label="display more actions"
edge="end"
color="inherit"
size="large"
>
<SettingsIcon />
</IconButton>
</Tooltip>
)}
</>,
);
return () => {
setTitle('');
setAction(null);
};
}, [t, source]);
2021-09-18 00:05:00 +04:30
2021-01-22 21:11:00 +03:30
return (
2024-09-14 03:01:07 +02:00
<StyledGridWrapper>
<ContentTypeMenu sx={{ top: `${appBarHeight}px` }}>
<ContentTypeButton
variant={contentType === SourceContentType.POPULAR ? 'contained' : 'outlined'}
startIcon={<FavoriteIcon />}
onClick={() => updateContentType(SourceContentType.POPULAR)}
>
{t('global.button.popular')}
</ContentTypeButton>
{source?.supportsLatest === undefined || source.supportsLatest ? (
<ContentTypeButton
disabled={!source?.supportsLatest}
variant={contentType === SourceContentType.LATEST ? 'contained' : 'outlined'}
startIcon={<NewReleasesIcon />}
onClick={() => updateContentType(SourceContentType.LATEST)}
>
{t('global.button.latest')}
</ContentTypeButton>
) : null}
<ContentTypeButton
variant={contentType === SourceContentType.SEARCH ? 'contained' : 'outlined'}
startIcon={<FilterListIcon />}
onClick={() => updateContentType(SourceContentType.SEARCH, query)}
>
{t('global.button.filter')}
</ContentTypeButton>
</ContentTypeMenu>
2024-10-06 16:15:01 +02:00
{(isLoading || !error || (!!error && !!mangas.length)) && (
<BaseMangaGrid
key={contentType}
gridWrapperProps={{ sx: { px: 1, pb: 1 } }}
mangas={mangas}
hasNextPage={hasNextPage}
loadMore={loadMore}
message={message}
messageExtra={messageExtra}
isLoading={isLoading}
gridLayout={sourceGridLayout}
mode="source"
inLibraryIndicator
/>
)}
{error && !mangas.length && (
<EmptyViewAbsoluteCentered
message={t('global.error.label.failed_to_load_data')}
messageExtra={error.message}
retry={() => loadPage(lastPageNum).catch(defaultPromiseErrorHandler('SourceMangas::refetch'))}
/>
)}
{error && !!mangas.length && (
<EmptyView
message={t('global.error.label.failed_to_load_data')}
messageExtra={error.message}
retry={() => loadPage(lastPageNum).catch(defaultPromiseErrorHandler('SourceMangas::refetch'))}
/>
)}
{contentType === SourceContentType.SEARCH && (
<SourceOptions
savedSearches={savedSearches}
selectSavedSearch={selectSavedSearch}
updateSavedSearches={handleSavedSearchesUpdate}
sourceFilter={filters}
updateFilterValue={setDialogFiltersToApply}
setTriggerUpdate={() => {
setFiltersToApply(dialogFiltersToApply);
}}
resetFilterValue={resetFilters}
update={dialogFiltersToApply}
/>
)}
</StyledGridWrapper>
2021-01-22 21:11:00 +03:30
);
2021-01-19 14:47:07 +03:30
}