Files
suwayomi-material-you-webui/src/screens/SourceMangas.tsx
schroda 5279339b86 Feature/update i18next to v23.x (#391)
* Update i18next to v23.x

Update to version 23.2.7

* Use new "ParseKeys" (old: "TFuncKey") type

* Remove unnecessary casts to string

Not needed anymore with the latest version
2023-07-04 21:53:25 +02:00

352 lines
13 KiB
TypeScript

/*
* 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 React, { useCallback, useContext, useEffect, useMemo, 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, Button, styled, useTheme, useMediaQuery } from '@mui/material';
import FavoriteIcon from '@mui/icons-material/Favorite';
import NewReleasesIcon from '@mui/icons-material/NewReleases';
import FilterListIcon from '@mui/icons-material/FilterList';
import { IManga, PaginatedMangaList, TranslationKey } from '@/typings';
import requestManager, { AbortableSWRInfiniteResponse } from '@/lib/RequestManager';
import { useDebounce } from '@/components/manga/hooks';
import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import SourceGridLayout from '@/components/source/GridLayouts';
import AppbarSearch from '@/components/util/AppbarSearch';
import SourceOptions from '@/components/source/SourceOptions';
import NavbarContext from '@/components/context/NavbarContext';
import SourceMangaGrid from '@/components/source/SourceMangaGrid';
const ContentTypeMenu = styled('div')(({ theme }) => ({
display: 'flex',
position: 'fixed',
top: '64px',
width: '100%',
zIndex: 1,
backgroundColor: theme.palette.background.default,
[theme.breakpoints.down('sm')]: {
top: '56px', // header height
},
}));
const ContentTypeButton = styled(Button)(() => ({
marginTop: '13px',
marginBottom: '13px',
marginLeft: '13px',
}));
const StyledGridWrapper = styled(Box, { shouldForwardProp: (prop) => prop !== 'hasContent' })<{ hasContent: boolean }>(
({ theme, hasContent }) => ({
// 62.5px ContentTypeMenu height (- padding of grid + grid item)
marginTop: `calc(62.5px ${hasContent ? '- 8px' : ''})`,
// header height - ContentTypeMenu height
minHeight: 'calc(100vh - 64px - 62.5px)',
position: 'relative',
[theme.breakpoints.down('sm')]: {
// 62.5px ContentTypeMenu - 8px margin diff header height (56px) (- padding of grid item)
marginTop: `calc(62.5px - 8px ${hasContent ? '- 8px' : ''})`,
// header height (+ 8px margin) - footer height - ContentTypeMenu height
minHeight: 'calc(100vh - 64px - 64px - 62.5px)',
},
}),
);
export enum SourceContentType {
POPULAR,
LATEST,
SEARCH,
FILTER,
}
interface IPos {
position: number;
state: any;
group?: number;
}
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.FILTER]: 'manga.error.label.no_matches',
[SourceContentType.SEARCH]: 'manga.error.label.no_mangas_found',
};
type SourceMangaResponse = Omit<AbortableSWRInfiniteResponse<PaginatedMangaList>, 'data'> & {
data: {
items: IManga[];
hasNextPage: boolean;
};
};
const getUniqueMangas = (mangas: IManga[]): IManga[] => {
const uniqueMangas: IManga[] = [];
mangas.forEach((manga) => {
const isDuplicate = uniqueMangas.some((uniqueManga) => uniqueManga.id === manga.id);
if (!isDuplicate) {
uniqueMangas.push(manga);
}
});
return uniqueMangas;
};
const useSourceManga = (
sourceId: string,
contentType: SourceContentType,
searchTerm: string | null | undefined,
filters: IPos[],
initialPages = 1,
): SourceMangaResponse => {
let result: AbortableSWRInfiniteResponse<PaginatedMangaList>;
switch (contentType) {
case SourceContentType.POPULAR:
result = requestManager.useGetSourcePopularMangas(sourceId, initialPages);
break;
case SourceContentType.LATEST:
result = requestManager.useGetSourceLatestMangas(sourceId, initialPages);
break;
case SourceContentType.SEARCH:
result = requestManager.useSourceQuickSearch(sourceId, searchTerm ?? '', [], initialPages);
break;
case SourceContentType.FILTER:
result = requestManager.useSourceQuickSearch(
sourceId,
'',
filters.map((filter) => {
const { position, state, group } = filter;
const isPartOfGroup = group !== undefined;
if (isPartOfGroup) {
return {
position: group,
state: JSON.stringify({
position,
state,
}),
};
}
return filter;
}),
initialPages,
{ disableCache: true },
);
break;
default:
throw new Error(`Unknown ContentType "${contentType}"`);
}
const pages = result.data;
const { hasNextPage } = pages?.[pages.length - 1] ?? { hasNextPage: false };
const items = useMemo(
() => (pages ?? []).map((page) => page.mangaList).reduce((prevList, list) => [...prevList, ...list], []),
[pages],
);
const uniqueItems = useMemo(() => getUniqueMangas(items), [items]);
return { ...result, data: { items: uniqueItems, hasNextPage } };
};
export default function SourceMangas() {
const { t } = useTranslation();
const { setTitle, setAction } = useContext(NavbarContext);
const theme = useTheme();
const isLargeScreen = useMediaQuery(theme.breakpoints.up('sm'));
const { sourceId } = useParams<{ sourceId: string }>();
const navigate = useNavigate();
const { contentType: currentLocationContentType = SourceContentType.POPULAR } =
useLocation<{
contentType: SourceContentType;
}>().state ?? {};
const { options } = useLibraryOptionsContext();
const [query] = useQueryParam('query', StringParam);
const [dialogFiltersToApply, setDialogFiltersToApply] = useState<IPos[]>([]);
const [filtersToApply, setFiltersToApply] = useState<IPos[]>([]);
const searchTerm = useDebounce(query, 1000);
const [resetScrollPosition, setResetScrollPosition] = useState(false);
const [contentType, setContentType] = useState(currentLocationContentType);
const {
data: { items: mangas, hasNextPage } = { items: [], hasNextPage: false },
isLoading,
size: lastPageNum,
setSize: setPages,
mutate: refreshData,
abortRequest,
} = useSourceManga(sourceId, contentType, searchTerm, filtersToApply, isLargeScreen ? 2 : 1);
const { data: filters = [], mutate: mutateFilters } = requestManager.useGetSourceFilters(sourceId);
const { data: source } = requestManager.useGetSource(sourceId);
const [triggerDataRefresh, setTriggerDataRefresh] = useState(false);
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>
<Link href="https://github.com/Suwayomi/Tachidesk-Server/wiki/Local-Source">
{t('source.local_source.label.guide')}
</Link>
</>
) : undefined;
const updateContentType = useCallback(
(newContentType: SourceContentType, updateLocationState: boolean = true) => {
if (updateLocationState) {
navigate('', { replace: true, state: { contentType: newContentType } });
}
setContentType(newContentType);
setResetScrollPosition(true);
},
[setContentType],
);
const isSearchTermAvailable = searchTerm && query?.length;
const setSearchContentType = isSearchTermAvailable && contentType !== SourceContentType.SEARCH;
if (setSearchContentType) {
updateContentType(SourceContentType.SEARCH, false);
}
const closeSearch = !query?.length && contentType === SourceContentType.SEARCH;
if (closeSearch) {
updateContentType(currentLocationContentType, false);
}
const loadMore = useCallback(() => {
if (!hasNextPage) {
return;
}
setPages(lastPageNum + 1);
}, [setPages, lastPageNum, hasNextPage]);
const resetFilters = useCallback(async () => {
setDialogFiltersToApply([]);
setFiltersToApply([]);
try {
// required since previous implementation used to set the filters on server side (server caches them), thus, it has to be made sure that they are reset
await requestManager.resetSourceFilters(sourceId);
mutateFilters();
} catch (error) {
// ignore
}
setTriggerDataRefresh(true);
}, [sourceId]);
useEffect(
() => () => {
if (contentType !== SourceContentType.SEARCH) {
return;
}
// INFO:
// with strict mode + dev mode the first request will be aborted. due to using SWR there won't be an
// immediate second request since it's the same key. instead the "second" request will be the error handling of SWR
abortRequest(new Error(`SourceMangas(${sourceId}): search string changed`));
setResetScrollPosition(true);
},
[searchTerm, contentType],
);
useEffect(() => {
if (!triggerDataRefresh) {
return;
}
refreshData();
setTriggerDataRefresh(false);
}, [triggerDataRefresh]);
useEffect(() => {
setTitle(source?.displayName ?? t('source.title'));
setAction(
<>
<AppbarSearch />
<SourceGridLayout />
{source?.isConfigurable && (
<IconButton
onClick={() => navigate(`/sources/${sourceId}/configure/`)}
aria-label="display more actions"
edge="end"
color="inherit"
size="large"
>
<SettingsIcon />
</IconButton>
)}
</>,
);
}, [t, source]);
useEffect(() => {
if (!resetScrollPosition) {
return;
}
window.scrollTo(0, 0);
setResetScrollPosition(false);
}, [resetScrollPosition]);
return (
<StyledGridWrapper hasContent={!!mangas.length}>
<ContentTypeMenu>
<ContentTypeButton
variant={contentType === SourceContentType.POPULAR ? 'contained' : 'outlined'}
startIcon={<FavoriteIcon />}
onClick={() => updateContentType(SourceContentType.POPULAR)}
>
{t('global.button.popular')}
</ContentTypeButton>
<ContentTypeButton
variant={contentType === SourceContentType.LATEST ? 'contained' : 'outlined'}
startIcon={<NewReleasesIcon />}
onClick={() => updateContentType(SourceContentType.LATEST)}
>
{t('global.button.latest')}
</ContentTypeButton>
<ContentTypeButton
variant={contentType === SourceContentType.FILTER ? 'contained' : 'outlined'}
startIcon={<FilterListIcon />}
onClick={() => updateContentType(SourceContentType.FILTER)}
>
{t('global.button.filter')}
</ContentTypeButton>
</ContentTypeMenu>
<SourceMangaGrid
mangas={mangas}
hasNextPage={hasNextPage}
loadMore={loadMore}
message={message}
messageExtra={messageExtra}
isLoading={isLoading}
gridLayout={options.SourcegridLayout}
/>
{contentType === SourceContentType.FILTER && (
<SourceOptions
sourceFilter={filters}
updateFilterValue={setDialogFiltersToApply}
setTriggerUpdate={() => {
setFiltersToApply(dialogFiltersToApply);
setTriggerDataRefresh(true);
}}
resetFilterValue={resetFilters}
update={dialogFiltersToApply}
/>
)}
</StyledGridWrapper>
);
}