Rename folder "modules" to "features"
This commit is contained in:
160
src/features/manga/Manga.constants.ts
Normal file
160
src/features/manga/Manga.constants.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* 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 { MangaStatus } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { TranslationKey } from '@/Base.types.ts';
|
||||
import { MangaAction, MangaIdInfo, MangaType } from '@/features/manga/Manga.types.ts';
|
||||
import {
|
||||
CHAPTER_ACTION_TO_CONFIRMATION_REQUIRED,
|
||||
CHAPTER_ACTION_TO_TRANSLATION,
|
||||
} from '@/features/chapter/Chapter.constants.ts';
|
||||
import { GqlMetaHolder } from '@/features/metadata/Metadata.types.ts';
|
||||
|
||||
export const FALLBACK_MANGA: MangaIdInfo & GqlMetaHolder = { id: -1 };
|
||||
|
||||
export const GLOBAL_READER_SETTINGS_MANGA: MangaIdInfo = { id: -2 };
|
||||
|
||||
export const MANGA_COVER_ASPECT_RATIO = '1 / 1.5';
|
||||
|
||||
export const MANGA_STATUS_TO_TRANSLATION: Record<MangaStatus, TranslationKey> = {
|
||||
[MangaStatus.Cancelled]: 'manga.status.cancelled',
|
||||
[MangaStatus.Completed]: 'manga.status.completed',
|
||||
[MangaStatus.Licensed]: 'manga.status.licensed',
|
||||
[MangaStatus.Ongoing]: 'manga.status.ongoing',
|
||||
[MangaStatus.OnHiatus]: 'manga.status.hiatus',
|
||||
[MangaStatus.PublishingFinished]: 'manga.status.publishing_finished',
|
||||
[MangaStatus.Unknown]: 'manga.status.unknown',
|
||||
};
|
||||
|
||||
export const MANGA_ACTION_TO_CONFIRMATION_REQUIRED: Record<
|
||||
MangaAction,
|
||||
{ always: boolean; bulkAction: boolean; bulkActionCountForce?: number }
|
||||
> = {
|
||||
...CHAPTER_ACTION_TO_CONFIRMATION_REQUIRED,
|
||||
remove_from_library: { always: true, bulkAction: true },
|
||||
change_categories: { always: false, bulkAction: false },
|
||||
migrate: { always: false, bulkAction: false },
|
||||
track: { always: false, bulkAction: false },
|
||||
};
|
||||
|
||||
export const MANGA_ACTION_TO_TRANSLATION: {
|
||||
[key in MangaAction]: {
|
||||
action: {
|
||||
single: TranslationKey;
|
||||
selected: TranslationKey;
|
||||
};
|
||||
confirmation?: TranslationKey;
|
||||
success: TranslationKey;
|
||||
error: TranslationKey;
|
||||
};
|
||||
} = {
|
||||
...CHAPTER_ACTION_TO_TRANSLATION,
|
||||
remove_from_library: {
|
||||
action: {
|
||||
single: 'manga.action.library.remove.label.action',
|
||||
selected: 'manga.action.library.remove.button.selected',
|
||||
},
|
||||
confirmation: 'manga.action.library.remove.label.confirmation',
|
||||
success: 'manga.action.library.remove.label.success',
|
||||
error: 'manga.action.library.remove.label.error',
|
||||
},
|
||||
change_categories: {
|
||||
action: {
|
||||
single: 'manga.action.category.label.action',
|
||||
selected: 'manga.action.category.button.selected',
|
||||
},
|
||||
confirmation: 'manga.action.category.label.confirmation',
|
||||
success: 'manga.action.category.label.success',
|
||||
error: 'manga.action.category.label.error',
|
||||
},
|
||||
migrate: {
|
||||
action: {
|
||||
single: 'global.button.migrate',
|
||||
selected: 'global.button.migrate', // not supported
|
||||
},
|
||||
success: 'manga.action.migrate.label.success',
|
||||
error: 'manga.action.migrate.label.error',
|
||||
},
|
||||
track: {
|
||||
action: {
|
||||
single: 'manga.action.track.add.label.action',
|
||||
selected: 'manga.action.track.add.label.action', // not supported
|
||||
},
|
||||
success: 'manga.action.track.add.label.success',
|
||||
error: 'manga.action.track.add.label.error',
|
||||
},
|
||||
};
|
||||
|
||||
// source: https://github.com/jobobby04/TachiyomiSY/blob/e0f40fad8c25459980ead51382c238462416f8d2/app/src/main/java/exh/util/MangaType.kt#L93
|
||||
// last synced: 2025-04-18 00:06
|
||||
export const SOURCES_BY_MANGA_TYPE: Record<MangaType, string[]> = {
|
||||
[MangaType.MANGA]: [],
|
||||
[MangaType.COMIC]: [
|
||||
'8muses',
|
||||
'allporncomic',
|
||||
'ciayo comics',
|
||||
'comicextra',
|
||||
'comicpunch',
|
||||
'cyanide',
|
||||
'dilbert',
|
||||
'eggporncomics',
|
||||
'existential comics',
|
||||
'hiveworks comics',
|
||||
'milftoon',
|
||||
'myhentaicomics',
|
||||
'myhentaigallery',
|
||||
'gunnerkrigg',
|
||||
'oglaf',
|
||||
'patch friday',
|
||||
'porncomix',
|
||||
'questionable content',
|
||||
'readcomiconline',
|
||||
'read comics online',
|
||||
'swords comic',
|
||||
'teabeer comics',
|
||||
'xkcd',
|
||||
],
|
||||
[MangaType.WEBTOON]: ['mangatoon', 'manmanga', 'toomics', 'webcomics', 'webtoons', 'webtoon'],
|
||||
[MangaType.MANHWA]: [
|
||||
'hiperdex',
|
||||
'hmanhwa',
|
||||
'instamanhwa',
|
||||
'manhwa18',
|
||||
'manhwa68',
|
||||
'manhwa365',
|
||||
'manhwahentaime',
|
||||
'manhwamanga',
|
||||
'manhwatop',
|
||||
'manhwa',
|
||||
'manytoon',
|
||||
'manwha',
|
||||
'readmanhwa',
|
||||
'skymanga',
|
||||
'toonily',
|
||||
'webtoonxyz',
|
||||
],
|
||||
[MangaType.MANHUA]: [
|
||||
'1st kiss manhua',
|
||||
'hero manhua',
|
||||
'manhuabox',
|
||||
'manhuaus',
|
||||
'manhuas',
|
||||
'manhuas',
|
||||
'readmanhua',
|
||||
'wuxiaworld',
|
||||
'manhua',
|
||||
],
|
||||
};
|
||||
|
||||
export const MANGA_TAGS_BY_MANGA_TYPE: Record<MangaType, TranslationKey[]> = {
|
||||
[MangaType.MANGA]: ['manga.type.manga'],
|
||||
[MangaType.COMIC]: ['manga.type.comic'],
|
||||
[MangaType.WEBTOON]: ['manga.type.webtoon', 'manga.type.long_strip'],
|
||||
[MangaType.MANHWA]: ['manga.type.manhwa', 'manga.type.long_strip'],
|
||||
[MangaType.MANHUA]: ['manga.type.manhua', 'manga.type.long_strip'],
|
||||
};
|
||||
104
src/features/manga/Manga.types.ts
Normal file
104
src/features/manga/Manga.types.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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 { LongPressPointerHandlers, LongPressResult } from 'use-long-press/lib/use-long-press.types';
|
||||
import { PopupState } from 'material-ui-popup-state/hooks';
|
||||
import type { JSX } from 'react';
|
||||
import { SelectableCollectionReturnType } from '@/features/collection/hooks/useSelectableCollection.ts';
|
||||
import { useManageMangaLibraryState } from '@/features/manga/hooks/useManageMangaLibraryState.tsx';
|
||||
import {
|
||||
ChapterType,
|
||||
MangaReaderFieldsFragment,
|
||||
MangaType as MangaTypeGql,
|
||||
Maybe,
|
||||
SourceType,
|
||||
TrackRecordType,
|
||||
} from '@/lib/graphql/generated/graphql.ts';
|
||||
import { SingleModeProps } from '@/features/manga/components/MangaActionMenuItems.tsx';
|
||||
import { GridLayout } from '@/features/core/Core.types.ts';
|
||||
import { ChapterListOptions } from '@/features/chapter/Chapter.types.ts';
|
||||
|
||||
export type MangaCardMode = 'default' | 'source' | 'migrate.search' | 'migrate.select' | 'duplicate';
|
||||
|
||||
type MangaCardBaseProps = Pick<MangaTypeGql, 'id' | 'title' | 'sourceId'> &
|
||||
Omit<SingleModeProps['manga'], 'downloadCount' | 'unreadCount' | 'chapters'> &
|
||||
Partial<Pick<MangaTypeGql, 'inLibrary' | 'downloadCount' | 'unreadCount'>> & {
|
||||
firstUnreadChapter?: Pick<ChapterType, 'id' | 'sourceOrder' | 'isRead'> | null;
|
||||
};
|
||||
|
||||
export type MangaIdInfo = Pick<MangaTypeGql, 'id'>;
|
||||
export type MangaChapterCountInfo = { chapters: Pick<MangaTypeGql['chapters'], 'totalCount'> };
|
||||
export type MangaInLibraryInfo = Pick<MangaTypeGql, 'inLibrary'>;
|
||||
export type MangaDownloadInfo = Pick<MangaTypeGql, 'downloadCount'> & MangaChapterCountInfo;
|
||||
export type MangaUnreadInfo = Pick<MangaTypeGql, 'unreadCount'> & MangaChapterCountInfo;
|
||||
export type MangaThumbnailInfo = Pick<MangaTypeGql, 'thumbnailUrl' | 'thumbnailUrlLastFetched'>;
|
||||
export type MangaTrackRecordInfo = MangaIdInfo & {
|
||||
trackRecords: { nodes: Pick<TrackRecordType, 'id' | 'trackerId'>[] };
|
||||
};
|
||||
export type MangaGenreInfo = Pick<MangaTypeGql, 'genre'>;
|
||||
export type MangaSourceIdInfo = Pick<MangaTypeGql, 'sourceId'>;
|
||||
export type MangaSourceNameInfo = { source?: Maybe<Pick<SourceType, 'name'>> };
|
||||
export type MangaSourceLngInfo = { source?: Maybe<Pick<SourceType, 'lang'>> };
|
||||
export type MangaArtistInfo = Pick<MangaTypeGql, 'artist'>;
|
||||
export type MangaAuthorInfo = Pick<MangaTypeGql, 'author'>;
|
||||
export type MangaTitleInfo = Pick<MangaTypeGql, 'title'>;
|
||||
export type MangaDescriptionInfo = Pick<MangaTypeGql, 'description'>;
|
||||
export type MangaStatusInfo = Pick<MangaTypeGql, 'status'>;
|
||||
|
||||
export type MigrateMode = 'copy' | 'migrate';
|
||||
|
||||
type MangaCardSpecificProps = MangaCardBaseProps & MangaThumbnailInfo;
|
||||
|
||||
export interface MangaCardProps {
|
||||
manga: MangaCardBaseProps;
|
||||
gridLayout?: GridLayout;
|
||||
inLibraryIndicator?: boolean;
|
||||
selected?: boolean | null;
|
||||
handleSelection?: SelectableCollectionReturnType<MangaTypeGql['id']>['handleSelection'];
|
||||
mode?: MangaCardMode;
|
||||
}
|
||||
|
||||
export type SpecificMangaCardProps = Omit<MangaCardProps, 'manga'> &
|
||||
Pick<ReturnType<typeof useManageMangaLibraryState>, 'isInLibrary'> & {
|
||||
manga: MangaCardSpecificProps;
|
||||
longPressBind: LongPressResult<LongPressPointerHandlers>;
|
||||
popupState: PopupState;
|
||||
handleClick: (event: React.MouseEvent | React.TouchEvent) => void;
|
||||
mangaLinkTo: string;
|
||||
continueReadingButton: JSX.Element;
|
||||
mangaBadges: JSX.Element;
|
||||
};
|
||||
|
||||
export type MangaMetadata = ChapterListOptions;
|
||||
|
||||
export type MangaMetadataKeys = keyof MangaMetadata;
|
||||
|
||||
export type MangaAction =
|
||||
| 'download'
|
||||
| 'delete'
|
||||
| 'mark_as_read'
|
||||
| 'mark_as_unread'
|
||||
| 'remove_from_library'
|
||||
| 'change_categories'
|
||||
| 'migrate'
|
||||
| 'track';
|
||||
|
||||
export type TMangaReader = MangaReaderFieldsFragment;
|
||||
|
||||
export enum MangaType {
|
||||
MANGA,
|
||||
COMIC,
|
||||
WEBTOON,
|
||||
MANHWA,
|
||||
MANHUA,
|
||||
}
|
||||
|
||||
export interface MangaLocationState {
|
||||
mangaTitle: string;
|
||||
mode: MangaCardMode | undefined;
|
||||
}
|
||||
17
src/features/manga/components/BaseMangaGrid.tsx
Normal file
17
src/features/manga/components/BaseMangaGrid.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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 { MangaGrid, IMangaGridProps } from '@/features/manga/components/MangaGrid.tsx';
|
||||
|
||||
type TMangaBaseGrid = Omit<IMangaGridProps['mangas'][number], 'downloadCount' | 'unreadCount' | 'chapters'>;
|
||||
|
||||
export function BaseMangaGrid(props: Omit<IMangaGridProps, 'mangas'> & { mangas: TMangaBaseGrid[] }) {
|
||||
const { mangas } = props;
|
||||
|
||||
return <MangaGrid gridWrapperProps={{ sx: { p: 1 } }} {...props} mangas={mangas as IMangaGridProps['mangas']} />;
|
||||
}
|
||||
52
src/features/manga/components/ContinueReadingButton.tsx
Normal file
52
src/features/manga/components/ContinueReadingButton.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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 Button from '@mui/material/Button';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
|
||||
import { Chapters } from '@/features/chapter/services/Chapters.ts';
|
||||
import { MUIUtil } from '@/lib/mui/MUI.util.ts';
|
||||
import { ChapterReadInfo, ChapterSourceOrderInfo } from '@/features/chapter/Chapter.types.ts';
|
||||
|
||||
export const ContinueReadingButton = ({
|
||||
showContinueReadingButton,
|
||||
chapter,
|
||||
mangaLinkTo,
|
||||
}: {
|
||||
showContinueReadingButton: boolean;
|
||||
chapter?: (ChapterSourceOrderInfo & ChapterReadInfo) | null;
|
||||
mangaLinkTo: string;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!showContinueReadingButton || !chapter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { sourceOrder } = chapter;
|
||||
const isFirstChapter = sourceOrder === 1;
|
||||
|
||||
return (
|
||||
<CustomTooltip title={t(isFirstChapter ? 'global.button.start' : 'global.button.resume')}>
|
||||
<Button
|
||||
{...MUIUtil.preventRippleProp()}
|
||||
variant="contained"
|
||||
size="small"
|
||||
sx={{ minWidth: 'unset', py: 0.5, px: 0.75 }}
|
||||
component={Link}
|
||||
to={`${mangaLinkTo}/chapter/${chapter.sourceOrder}`}
|
||||
state={Chapters.getReaderOpenChapterLocationState(chapter)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PlayArrowIcon />
|
||||
</Button>
|
||||
</CustomTooltip>
|
||||
);
|
||||
};
|
||||
207
src/features/manga/components/MangaActionMenuItems.tsx
Normal file
207
src/features/manga/components/MangaActionMenuItems.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* 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 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 FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
|
||||
import Label from '@mui/icons-material/Label';
|
||||
import { useMemo, useState } from 'react';
|
||||
import SyncAltIcon from '@mui/icons-material/SyncAlt';
|
||||
import { Link } from 'react-router-dom';
|
||||
import SyncIcon from '@mui/icons-material/Sync';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import { Mangas } from '@/features/manga/services/Mangas.ts';
|
||||
import { SelectableCollectionReturnType } from '@/features/collection/hooks/useSelectableCollection.ts';
|
||||
import { MenuItem } from '@/features/core/components/menu/MenuItem.tsx';
|
||||
import {
|
||||
createGetMenuItemTitle,
|
||||
createIsMenuItemDisabled,
|
||||
createShouldShowMenuItem,
|
||||
} from '@/features/core/components/menu/Menu.utils.ts';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import { TrackManga } from '@/features/tracker/components/TrackManga.tsx';
|
||||
import { useCategorySelect } from '@/features/category/hooks/useCategorySelect.tsx';
|
||||
import { ChaptersDownloadActionMenuItems } from '@/features/chapter/components/actions/ChaptersDownloadActionMenuItems.tsx';
|
||||
import { NestedMenuItem } from '@/features/core/components/menu/NestedMenuItem.tsx';
|
||||
import { MangaChapterStatFieldsFragment, MangaType } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { MangaAction, MangaDownloadInfo, MangaIdInfo, MangaUnreadInfo } from '@/features/manga/Manga.types.ts';
|
||||
import { MANGA_ACTION_TO_TRANSLATION } from '@/features/manga/Manga.constants.ts';
|
||||
import { AppRoutes } from '@/features/core/AppRoute.constants.ts';
|
||||
|
||||
const ACTION_DISABLES_SELECTION_MODE: MangaAction[] = ['remove_from_library'] as const;
|
||||
|
||||
type BaseProps = { onClose: (selectionModeState: boolean) => void; setHideMenu: (hide: boolean) => void };
|
||||
|
||||
export type SingleModeProps = {
|
||||
manga: Pick<MangaType, 'id' | 'title' | 'sourceId'> & MangaDownloadInfo & MangaUnreadInfo;
|
||||
handleSelection?: SelectableCollectionReturnType<MangaType['id']>['handleSelection'];
|
||||
};
|
||||
|
||||
type SelectModeProps = {
|
||||
selectedMangas: MangaChapterStatFieldsFragment[];
|
||||
};
|
||||
|
||||
type Props =
|
||||
| (BaseProps & SingleModeProps & PropertiesNever<SelectModeProps>)
|
||||
| (BaseProps & PropertiesNever<SingleModeProps> & SelectModeProps);
|
||||
|
||||
export const MangaActionMenuItems = ({
|
||||
manga,
|
||||
handleSelection,
|
||||
selectedMangas: passedSelectedMangas,
|
||||
onClose,
|
||||
setHideMenu,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isTrackDialogOpen, setIsTrackDialogOpen] = useState(false);
|
||||
|
||||
const isSingleMode = !!manga;
|
||||
const selectedMangas = passedSelectedMangas ?? [];
|
||||
|
||||
const getMenuItemTitle = createGetMenuItemTitle(isSingleMode, MANGA_ACTION_TO_TRANSLATION);
|
||||
const shouldShowMenuItem = createShouldShowMenuItem(isSingleMode);
|
||||
const isMenuItemDisabled = createIsMenuItemDisabled(isSingleMode);
|
||||
|
||||
const isFullyDownloaded = !!manga && manga.downloadCount === manga.chapters.totalCount;
|
||||
const hasDownloadedChapters = !!manga?.downloadCount;
|
||||
const hasUnreadChapters = !!manga?.unreadCount;
|
||||
const hasReadChapters = !!manga && manga.unreadCount !== manga.chapters.totalCount;
|
||||
|
||||
const { openCategorySelect, CategorySelectComponent } = useCategorySelect({
|
||||
mangaId: manga?.id,
|
||||
mangaIds: passedSelectedMangas ? Mangas.getIds(selectedMangas) : undefined,
|
||||
onClose: () => onClose(true),
|
||||
addToLibrary: false,
|
||||
});
|
||||
|
||||
const handleSelect = () => {
|
||||
handleSelection?.(manga.id, true);
|
||||
onClose(true);
|
||||
};
|
||||
|
||||
const performAction = (action: MangaAction, mangas: MangaIdInfo[]) => {
|
||||
Mangas.performAction(action, manga ? [manga.id] : Mangas.getIds(mangas), {
|
||||
wasManuallyMarkedAsRead: true,
|
||||
}).catch(defaultPromiseErrorHandler(`MangaActionMenuItems:performAction(${action})`));
|
||||
|
||||
onClose(!ACTION_DISABLES_SELECTION_MODE.includes(action));
|
||||
};
|
||||
|
||||
const { downloadableMangas, downloadedMangas, unreadMangas, readMangas } = useMemo(
|
||||
() => ({
|
||||
downloadableMangas: [
|
||||
...Mangas.getNotDownloaded(selectedMangas),
|
||||
...Mangas.getPartiallyDownloaded(selectedMangas),
|
||||
],
|
||||
downloadedMangas: [
|
||||
...Mangas.getPartiallyDownloaded(selectedMangas),
|
||||
...Mangas.getFullyDownloaded(selectedMangas),
|
||||
],
|
||||
unreadMangas: [...Mangas.getUnread(selectedMangas), ...Mangas.getPartiallyRead(selectedMangas)],
|
||||
readMangas: [...Mangas.getPartiallyRead(selectedMangas), ...Mangas.getFullyRead(selectedMangas)],
|
||||
}),
|
||||
[selectedMangas],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!handleSelection && isSingleMode && (
|
||||
<MenuItem onClick={handleSelect} Icon={CheckBoxOutlineBlank} title={t('chapter.action.label.select')} />
|
||||
)}
|
||||
{shouldShowMenuItem(!isFullyDownloaded) && (
|
||||
<NestedMenuItem
|
||||
disabled={isMenuItemDisabled(!downloadableMangas.length)}
|
||||
LeftIcon={Download}
|
||||
label={getMenuItemTitle('download', downloadableMangas.length)}
|
||||
parentMenuOpen
|
||||
>
|
||||
<ChaptersDownloadActionMenuItems
|
||||
mangaIds={isSingleMode ? [manga.id] : Mangas.getIds(selectedMangas)}
|
||||
closeMenu={() => onClose(true)}
|
||||
/>
|
||||
</NestedMenuItem>
|
||||
)}
|
||||
{shouldShowMenuItem(hasDownloadedChapters) && (
|
||||
<MenuItem
|
||||
Icon={Delete}
|
||||
disabled={isMenuItemDisabled(!downloadedMangas.length)}
|
||||
onClick={() => performAction('delete', downloadedMangas)}
|
||||
title={getMenuItemTitle('delete', downloadedMangas.length)}
|
||||
/>
|
||||
)}
|
||||
{shouldShowMenuItem(hasUnreadChapters) && (
|
||||
<MenuItem
|
||||
Icon={Done}
|
||||
disabled={isMenuItemDisabled(!unreadMangas.length)}
|
||||
onClick={() => performAction('mark_as_read', unreadMangas)}
|
||||
title={getMenuItemTitle('mark_as_read', unreadMangas.length)}
|
||||
/>
|
||||
)}
|
||||
{shouldShowMenuItem(hasReadChapters) && (
|
||||
<MenuItem
|
||||
Icon={RemoveDone}
|
||||
disabled={isMenuItemDisabled(!readMangas.length)}
|
||||
onClick={() => performAction('mark_as_unread', readMangas)}
|
||||
title={getMenuItemTitle('mark_as_unread', readMangas.length)}
|
||||
/>
|
||||
)}
|
||||
{isSingleMode && (
|
||||
<Link
|
||||
to={AppRoutes.migrate.childRoutes.search.path(manga?.sourceId ?? -1, manga?.id ?? -1, manga?.title)}
|
||||
state={{ mangaTitle: manga?.title }}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<MenuItem Icon={SyncAltIcon} title={getMenuItemTitle('migrate', selectedMangas.length)} />
|
||||
</Link>
|
||||
)}
|
||||
{isSingleMode && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setIsTrackDialogOpen(true);
|
||||
setHideMenu(true);
|
||||
}}
|
||||
Icon={SyncIcon}
|
||||
title={getMenuItemTitle('track', selectedMangas.length)}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
openCategorySelect(true);
|
||||
setHideMenu(true);
|
||||
}}
|
||||
Icon={Label}
|
||||
title={getMenuItemTitle('change_categories', selectedMangas.length)}
|
||||
/>
|
||||
<MenuItem
|
||||
onClick={() => performAction('remove_from_library', selectedMangas)}
|
||||
Icon={FavoriteBorderIcon}
|
||||
title={getMenuItemTitle('remove_from_library', selectedMangas.length)}
|
||||
/>
|
||||
{CategorySelectComponent}
|
||||
{isTrackDialogOpen && (
|
||||
<Dialog
|
||||
open
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
scroll="paper"
|
||||
onClose={() => {
|
||||
setIsTrackDialogOpen(false);
|
||||
onClose(true);
|
||||
}}
|
||||
>
|
||||
<TrackManga manga={manga!} />
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
98
src/features/manga/components/MangaBadges.tsx
Normal file
98
src/features/manga/components/MangaBadges.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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 { styled } from '@mui/material/styles';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Button from '@mui/material/Button';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { MangaCardMode } from '@/features/manga/Manga.types.ts';
|
||||
import { MediaQuery } from '@/features/core/utils/MediaQuery.tsx';
|
||||
import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
|
||||
import { MUIUtil } from '@/lib/mui/MUI.util.ts';
|
||||
|
||||
const BadgeContainer = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
height: 'fit-content',
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
overflow: 'hidden',
|
||||
}));
|
||||
|
||||
const Badge = styled(Typography)(({ theme }) => ({
|
||||
color: theme.palette.primary.contrastText,
|
||||
paddingInline: theme.spacing(0.3),
|
||||
}));
|
||||
|
||||
export const MangaBadges = ({
|
||||
inLibraryIndicator,
|
||||
updateLibraryState,
|
||||
isInLibrary,
|
||||
unread,
|
||||
downloadCount,
|
||||
mode,
|
||||
}: {
|
||||
inLibraryIndicator?: boolean;
|
||||
updateLibraryState: () => void;
|
||||
isInLibrary: boolean;
|
||||
unread?: number;
|
||||
downloadCount?: number;
|
||||
mode: MangaCardMode;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isTouchDevice = MediaQuery.useIsTouchDevice();
|
||||
|
||||
const {
|
||||
settings: { showUnreadBadge, showDownloadBadge },
|
||||
} = useMetadataServerSettings();
|
||||
|
||||
return (
|
||||
<BadgeContainer>
|
||||
{!isTouchDevice && inLibraryIndicator && mode === 'source' && (
|
||||
<Button
|
||||
className="source-manga-library-state-button"
|
||||
component="div"
|
||||
variant="contained"
|
||||
size="small"
|
||||
{...MUIUtil.preventRippleProp()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
updateLibraryState();
|
||||
}}
|
||||
sx={{
|
||||
display: 'none',
|
||||
}}
|
||||
color={isInLibrary ? 'error' : 'primary'}
|
||||
>
|
||||
{t(isInLibrary ? 'manga.action.library.remove.label.action' : 'manga.button.add_to_library')}
|
||||
</Button>
|
||||
)}
|
||||
{inLibraryIndicator && isInLibrary && (
|
||||
<Typography
|
||||
className="source-manga-library-state-indicator"
|
||||
sx={{ backgroundColor: 'primary.dark', color: 'primary.contrastText', p: 0.3 }}
|
||||
>
|
||||
{t('manga.button.in_library')}
|
||||
</Typography>
|
||||
)}
|
||||
{((showUnreadBadge && mode === 'default') || mode === 'duplicate') && (unread ?? 0) > 0 && (
|
||||
<Badge sx={{ backgroundColor: 'primary.main', color: 'primary.contrastText' }}>{unread}</Badge>
|
||||
)}
|
||||
{((showDownloadBadge && mode === 'default') || mode === 'duplicate') && (downloadCount ?? 0) > 0 && (
|
||||
<Badge
|
||||
sx={{
|
||||
backgroundColor: 'secondary.main',
|
||||
color: 'secondary.contrastText',
|
||||
}}
|
||||
>
|
||||
{downloadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</BadgeContainer>
|
||||
);
|
||||
};
|
||||
372
src/features/manga/components/MangaGrid.tsx
Normal file
372
src/features/manga/components/MangaGrid.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
/*
|
||||
* 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, {
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type JSX,
|
||||
} from 'react';
|
||||
import Grid, { GridTypeMap } from '@mui/material/Grid';
|
||||
import Box, { BoxProps } from '@mui/material/Box';
|
||||
import { GridItemProps } from 'react-virtuoso';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EmptyViewAbsoluteCentered } from '@/features/core/components/feedback/EmptyViewAbsoluteCentered.tsx';
|
||||
import { LoadingPlaceholder } from '@/features/core/components/feedback/LoadingPlaceholder.tsx';
|
||||
import { MangaCard } from '@/features/manga/components/cards/MangaCard.tsx';
|
||||
import { SelectableCollectionReturnType } from '@/features/collection/hooks/useSelectableCollection.ts';
|
||||
import { DEFAULT_FULL_FAB_HEIGHT } from '@/features/core/components/buttons/StyledFab.tsx';
|
||||
import { MangaCardProps } from '@/features/manga/Manga.types.ts';
|
||||
import { MangaType } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { useResizeObserver } from '@/features/core/hooks/useResizeObserver.tsx';
|
||||
import { useNavBarContext } from '@/features/navigation-bar/contexts/NavbarContext.tsx';
|
||||
import { GridLayout } from '@/features/core/Core.types.ts';
|
||||
import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
|
||||
import { VirtuosoGridPersisted } from '@/lib/virtuoso/Component/VirtuosoGridPersisted.tsx';
|
||||
|
||||
const GridContainer = React.forwardRef<HTMLDivElement, GridTypeMap['props']>(({ children, ...props }, ref) => (
|
||||
<Grid {...props} ref={ref} container spacing={1}>
|
||||
{children}
|
||||
</Grid>
|
||||
));
|
||||
|
||||
const GridItemContainerWithDimension = (
|
||||
dimensions: number,
|
||||
itemWidth: number,
|
||||
gridLayout?: GridLayout,
|
||||
maxColumns: number = 12,
|
||||
) => {
|
||||
const itemsPerRow = Math.ceil(dimensions / itemWidth);
|
||||
const columnsPerItem = gridLayout === GridLayout.List ? maxColumns : maxColumns / itemsPerRow;
|
||||
|
||||
// MUI GridProps and Virtuoso GridItemProps use different types for the "ref" prop which conflict with each other
|
||||
return ({ children, ...itemProps }: GridTypeMap['props'] & Omit<Partial<GridItemProps>, 'ref'>) => (
|
||||
<Grid {...itemProps} size={columnsPerItem}>
|
||||
{children}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
type TManga = MangaCardProps['manga'];
|
||||
|
||||
const createMangaCard = (
|
||||
manga: TManga,
|
||||
gridLayout?: GridLayout,
|
||||
inLibraryIndicator?: boolean,
|
||||
isSelectModeActive: boolean = false,
|
||||
selectedMangaIds?: MangaType['id'][],
|
||||
handleSelection?: DefaultGridProps['handleSelection'],
|
||||
mode?: MangaCardProps['mode'],
|
||||
) => (
|
||||
<MangaCard
|
||||
manga={manga}
|
||||
gridLayout={gridLayout}
|
||||
inLibraryIndicator={inLibraryIndicator}
|
||||
selected={isSelectModeActive ? selectedMangaIds?.includes(manga.id) : null}
|
||||
handleSelection={handleSelection}
|
||||
mode={mode}
|
||||
/>
|
||||
);
|
||||
|
||||
type DefaultGridProps = Pick<MangaCardProps, 'mode'> & {
|
||||
isLoading: boolean;
|
||||
mangas: TManga[];
|
||||
inLibraryIndicator?: boolean;
|
||||
GridItemContainer: (props: GridTypeMap['props'] & Partial<GridItemProps>) => JSX.Element;
|
||||
gridLayout?: GridLayout;
|
||||
isSelectModeActive?: boolean;
|
||||
selectedMangaIds?: Required<MangaType['id']>[];
|
||||
handleSelection?: SelectableCollectionReturnType<MangaType['id']>['handleSelection'];
|
||||
};
|
||||
|
||||
const HorizontalGrid = forwardRef(
|
||||
(
|
||||
{
|
||||
isLoading,
|
||||
mangas,
|
||||
inLibraryIndicator,
|
||||
GridItemContainer,
|
||||
gridLayout,
|
||||
isSelectModeActive,
|
||||
selectedMangaIds,
|
||||
handleSelection,
|
||||
mode,
|
||||
}: DefaultGridProps,
|
||||
ref: ForwardedRef<HTMLDivElement | null>,
|
||||
) => (
|
||||
<Grid
|
||||
ref={ref}
|
||||
container
|
||||
spacing={1}
|
||||
sx={{
|
||||
width: '100%',
|
||||
overflowX: 'auto',
|
||||
display: '-webkit-inline-box',
|
||||
flexWrap: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<LoadingPlaceholder />
|
||||
) : (
|
||||
mangas.map((manga) => (
|
||||
<GridItemContainer key={manga.id}>
|
||||
{createMangaCard(
|
||||
manga,
|
||||
gridLayout,
|
||||
inLibraryIndicator,
|
||||
isSelectModeActive,
|
||||
selectedMangaIds,
|
||||
handleSelection,
|
||||
mode,
|
||||
)}
|
||||
</GridItemContainer>
|
||||
))
|
||||
)}
|
||||
</Grid>
|
||||
),
|
||||
);
|
||||
|
||||
export const MANGA_GRID_SNAPSHOT_KEY = 'MangaGrid-snapshot-location';
|
||||
|
||||
const VerticalGrid = forwardRef(
|
||||
(
|
||||
{
|
||||
isLoading,
|
||||
mangas,
|
||||
inLibraryIndicator,
|
||||
GridItemContainer,
|
||||
gridLayout,
|
||||
hasNextPage,
|
||||
loadMore,
|
||||
isSelectModeActive,
|
||||
selectedMangaIds,
|
||||
handleSelection,
|
||||
mode,
|
||||
}: DefaultGridProps & {
|
||||
hasNextPage: boolean;
|
||||
loadMore: () => void;
|
||||
},
|
||||
ref: ForwardedRef<HTMLDivElement | null>,
|
||||
) => (
|
||||
<>
|
||||
<Box ref={ref}>
|
||||
<VirtuosoGridPersisted
|
||||
persistKey={MANGA_GRID_SNAPSHOT_KEY}
|
||||
useWindowScroll
|
||||
increaseViewportBy={window.innerHeight * 0.5}
|
||||
totalCount={mangas.length}
|
||||
components={{
|
||||
List: GridContainer,
|
||||
Item: GridItemContainer,
|
||||
}}
|
||||
endReached={() => loadMore()}
|
||||
computeItemKey={(index) => mangas[index].id}
|
||||
itemContent={(index) =>
|
||||
createMangaCard(
|
||||
mangas[index],
|
||||
gridLayout,
|
||||
inLibraryIndicator,
|
||||
isSelectModeActive,
|
||||
selectedMangaIds,
|
||||
handleSelection,
|
||||
mode,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
{/* render div to prevent UI jumping around when showing/hiding loading placeholder */
|
||||
/* eslint-disable-next-line no-nested-ternary */}
|
||||
{isSelectModeActive && gridLayout === GridLayout.List ? (
|
||||
<Box sx={{ paddingBottom: DEFAULT_FULL_FAB_HEIGHT }} />
|
||||
) : // eslint-disable-next-line no-nested-ternary
|
||||
isLoading ? (
|
||||
<LoadingPlaceholder />
|
||||
) : hasNextPage ? (
|
||||
<div style={{ height: '75px' }} />
|
||||
) : null}
|
||||
</>
|
||||
),
|
||||
);
|
||||
|
||||
export interface IMangaGridProps
|
||||
extends Omit<DefaultGridProps, 'GridItemContainer'>,
|
||||
Partial<React.ComponentProps<typeof EmptyViewAbsoluteCentered>> {
|
||||
hasNextPage: boolean;
|
||||
loadMore: () => void;
|
||||
horizontal?: boolean | undefined;
|
||||
noFaces?: boolean | undefined;
|
||||
gridWrapperProps?: Omit<BoxProps, 'ref'>;
|
||||
}
|
||||
|
||||
export const MangaGrid: React.FC<IMangaGridProps> = ({
|
||||
mangas,
|
||||
isLoading,
|
||||
message,
|
||||
messageExtra,
|
||||
hasNextPage,
|
||||
loadMore,
|
||||
gridLayout,
|
||||
horizontal,
|
||||
noFaces,
|
||||
inLibraryIndicator,
|
||||
isSelectModeActive,
|
||||
selectedMangaIds,
|
||||
handleSelection,
|
||||
mode,
|
||||
retry,
|
||||
gridWrapperProps,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { navBarWidth } = useNavBarContext();
|
||||
const {
|
||||
settings: { mangaGridItemWidth },
|
||||
} = useMetadataServerSettings();
|
||||
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const gridWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [dimensions, setDimensions] = useState(
|
||||
gridWrapperRef.current?.offsetWidth ?? Math.max(0, document.documentElement.offsetWidth - navBarWidth),
|
||||
);
|
||||
const GridItemContainer = useMemo(
|
||||
() => GridItemContainerWithDimension(dimensions, mangaGridItemWidth, gridLayout),
|
||||
[dimensions, mangaGridItemWidth, gridLayout],
|
||||
);
|
||||
|
||||
// always show vertical scrollbar to prevent https://github.com/Suwayomi/Suwayomi-WebUI/issues/758
|
||||
useLayoutEffect(() => {
|
||||
if (horizontal) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// in case "overflow" is currently set to "hidden" that (most likely) means that a MUI modal is open and locks the scrollbar
|
||||
// once this modal is closed MUI restores the previous "overflow" value, thus, reverting the just set "overflow" value
|
||||
let timeout: NodeJS.Timeout;
|
||||
const changeStyle = (timeoutMS: number) => {
|
||||
timeout = setTimeout(() => {
|
||||
if (document.body.style.overflow.includes('hidden')) {
|
||||
changeStyle(250);
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.style.overflowY = gridLayout === GridLayout.List ? 'auto' : 'scroll';
|
||||
}, timeoutMS);
|
||||
};
|
||||
|
||||
changeStyle(0);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [gridLayout]);
|
||||
useLayoutEffect(
|
||||
() => () => {
|
||||
if (horizontal) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.style.overflowY = 'auto';
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useResizeObserver(
|
||||
gridWrapperRef,
|
||||
useCallback(() => {
|
||||
const getDimensions = () => {
|
||||
const gridWidth = gridWrapperRef.current?.offsetWidth;
|
||||
|
||||
if (!gridWidth) {
|
||||
return document.documentElement.offsetWidth - navBarWidth;
|
||||
}
|
||||
|
||||
return gridWidth;
|
||||
};
|
||||
|
||||
setDimensions(getDimensions());
|
||||
}, [navBarWidth]),
|
||||
);
|
||||
|
||||
useResizeObserver(
|
||||
gridRef,
|
||||
useCallback(
|
||||
(entries, resizeObserver) => {
|
||||
const gridHeight = entries[0].target.clientHeight;
|
||||
const isScrollbarVisible = gridHeight > document.documentElement.clientHeight;
|
||||
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!gridHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isScrollbarVisible) {
|
||||
resizeObserver.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
loadMore();
|
||||
resizeObserver.disconnect();
|
||||
},
|
||||
[gridRef, loadMore, isLoading],
|
||||
),
|
||||
);
|
||||
|
||||
const hasNoItems = !isLoading && mangas.length === 0;
|
||||
if (hasNoItems) {
|
||||
return (
|
||||
<EmptyViewAbsoluteCentered
|
||||
noFaces={noFaces}
|
||||
message={message ?? t('manga.error.label.no_mangas_found')}
|
||||
messageExtra={messageExtra}
|
||||
retry={retry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box {...gridWrapperProps} ref={gridWrapperRef} sx={{ ...gridWrapperProps?.sx, overflow: 'hidden' }}>
|
||||
{horizontal ? (
|
||||
<HorizontalGrid
|
||||
ref={gridRef}
|
||||
isLoading={isLoading}
|
||||
mangas={mangas}
|
||||
inLibraryIndicator={inLibraryIndicator}
|
||||
GridItemContainer={GridItemContainer}
|
||||
gridLayout={gridLayout}
|
||||
isSelectModeActive={isSelectModeActive}
|
||||
selectedMangaIds={selectedMangaIds}
|
||||
handleSelection={handleSelection}
|
||||
mode={mode}
|
||||
/>
|
||||
) : (
|
||||
<VerticalGrid
|
||||
ref={gridRef}
|
||||
isLoading={isLoading}
|
||||
mangas={mangas}
|
||||
inLibraryIndicator={inLibraryIndicator}
|
||||
GridItemContainer={GridItemContainer}
|
||||
hasNextPage={hasNextPage}
|
||||
loadMore={loadMore}
|
||||
gridLayout={gridLayout}
|
||||
isSelectModeActive={isSelectModeActive}
|
||||
selectedMangaIds={selectedMangaIds}
|
||||
handleSelection={handleSelection}
|
||||
mode={mode}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
112
src/features/manga/components/MangaOptionButton.tsx
Normal file
112
src/features/manga/components/MangaOptionButton.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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 { BaseSyntheticEvent, ChangeEvent, useMemo, forwardRef, ForwardedRef } from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
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/hooks';
|
||||
import { bindTrigger } from 'material-ui-popup-state';
|
||||
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
|
||||
import { SelectableCollectionReturnType } from '@/features/collection/hooks/useSelectableCollection.ts';
|
||||
import { MangaType } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { MUIUtil } from '@/lib/mui/MUI.util.ts';
|
||||
|
||||
export const MangaOptionButton = forwardRef(
|
||||
(
|
||||
{
|
||||
id,
|
||||
selected,
|
||||
handleSelection,
|
||||
asCheckbox = false,
|
||||
popupState,
|
||||
}: {
|
||||
id: number;
|
||||
selected?: boolean | null;
|
||||
handleSelection?: SelectableCollectionReturnType<MangaType['id']>['handleSelection'];
|
||||
asCheckbox?: boolean;
|
||||
popupState: PopupState;
|
||||
},
|
||||
ref: ForwardedRef<HTMLButtonElement | null>,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const bindTriggerProps = useMemo(() => bindTrigger(popupState), [popupState]);
|
||||
|
||||
const preventDefaultAction = (e: BaseSyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleSelectionChange = (e: ChangeEvent, isSelected: boolean) => {
|
||||
preventDefaultAction(e);
|
||||
handleSelection?.(id, isSelected);
|
||||
};
|
||||
|
||||
if (!handleSelection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isSelected = selected !== null;
|
||||
if (isSelected) {
|
||||
if (!asCheckbox) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomTooltip title={t(selected ? 'global.button.deselect' : 'global.button.select')}>
|
||||
<Checkbox {...MUIUtil.preventRippleProp()} checked={selected} onChange={handleSelectionChange} />
|
||||
</CustomTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (asCheckbox) {
|
||||
return (
|
||||
<CustomTooltip title={t('global.button.options')}>
|
||||
<IconButton
|
||||
ref={ref}
|
||||
{...MUIUtil.preventRippleProp(bindTriggerProps, { onClick: preventDefaultAction })}
|
||||
aria-label="more"
|
||||
>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomTooltip title={t('global.button.options')}>
|
||||
<Button
|
||||
ref={ref}
|
||||
{...MUIUtil.preventRippleProp(bindTriggerProps, { onClick: preventDefaultAction })}
|
||||
className="manga-option-button"
|
||||
size="small"
|
||||
variant="contained"
|
||||
sx={{
|
||||
minWidth: 'unset',
|
||||
paddingX: '0',
|
||||
paddingY: '2.5px',
|
||||
visibility: popupState.isOpen ? 'visible' : 'hidden',
|
||||
pointerEvents: 'none',
|
||||
'@media not (pointer: fine)': {
|
||||
visibility: 'hidden',
|
||||
width: 0,
|
||||
height: 0,
|
||||
p: 0,
|
||||
m: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon />
|
||||
</Button>
|
||||
</CustomTooltip>
|
||||
);
|
||||
},
|
||||
);
|
||||
162
src/features/manga/components/MangaToolbarMenu.tsx
Normal file
162
src/features/manga/components/MangaToolbarMenu.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* 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 Label from '@mui/icons-material/Label';
|
||||
import MoreHoriz from '@mui/icons-material/MoreHoriz';
|
||||
import Refresh from '@mui/icons-material/Refresh';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import SyncAltIcon from '@mui/icons-material/SyncAlt';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
|
||||
import { useCategorySelect } from '@/features/category/hooks/useCategorySelect.tsx';
|
||||
import { MangaType } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { AppRoutes } from '@/features/core/AppRoute.constants.ts';
|
||||
|
||||
interface IProps {
|
||||
manga: Pick<MangaType, 'id' | 'inLibrary' | 'sourceId' | 'title'>;
|
||||
onRefresh: () => any;
|
||||
refreshing: boolean;
|
||||
}
|
||||
|
||||
export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const theme = useTheme();
|
||||
const isLargeScreen = useMediaQuery(theme.breakpoints.up('sm'));
|
||||
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const { openCategorySelect, CategorySelectComponent } = useCategorySelect({
|
||||
mangaId: manga.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLargeScreen && (
|
||||
<>
|
||||
<CustomTooltip title={t('manga.label.reload_from_source')} disabled={refreshing}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
onRefresh();
|
||||
}}
|
||||
disabled={refreshing}
|
||||
color="inherit"
|
||||
>
|
||||
<Refresh />
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
{manga.inLibrary && (
|
||||
<>
|
||||
<CustomTooltip title={t('global.button.migrate')}>
|
||||
<Link
|
||||
to={AppRoutes.migrate.childRoutes.search.path(
|
||||
manga.sourceId,
|
||||
manga.id,
|
||||
manga.title,
|
||||
)}
|
||||
state={{ mangaTitle: manga.title }}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<IconButton color="inherit">
|
||||
<SyncAltIcon />
|
||||
</IconButton>
|
||||
</Link>
|
||||
</CustomTooltip>
|
||||
<CustomTooltip title={t('manga.label.edit_categories')}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
openCategorySelect(true);
|
||||
}}
|
||||
color="inherit"
|
||||
>
|
||||
<Label />
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isLargeScreen && (
|
||||
<>
|
||||
<IconButton
|
||||
id="chaptersMenuButton"
|
||||
aria-controls={open ? 'chaptersMenu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={(e) => setAnchorEl(e.currentTarget)}
|
||||
color="inherit"
|
||||
>
|
||||
<MoreHoriz />
|
||||
</IconButton>
|
||||
<Menu
|
||||
id="chaptersMenu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'chaptersMenuButton',
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
onRefresh();
|
||||
handleClose();
|
||||
}}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Refresh fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('manga.label.reload_from_source')}</ListItemText>
|
||||
</MenuItem>
|
||||
{manga.inLibrary && [
|
||||
<MenuItem
|
||||
key="migrate"
|
||||
component={Link}
|
||||
to={AppRoutes.migrate.childRoutes.search.path(manga.sourceId, manga.id, manga.title)}
|
||||
state={{ mangaTitle: manga.title }}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<SyncAltIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('migrate.title')}</ListItemText>
|
||||
</MenuItem>,
|
||||
<MenuItem
|
||||
key="categories"
|
||||
onClick={() => {
|
||||
openCategorySelect(true);
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Label fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('manga.label.edit_categories')}</ListItemText>
|
||||
</MenuItem>,
|
||||
]}
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
|
||||
{CategorySelectComponent}
|
||||
</>
|
||||
);
|
||||
};
|
||||
32
src/features/manga/components/ResumeFAB.tsx
Normal file
32
src/features/manga/components/ResumeFAB.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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 { Link } from 'react-router-dom';
|
||||
import PlayArrow from '@mui/icons-material/PlayArrow';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StyledFab } from '@/features/core/components/buttons/StyledFab.tsx';
|
||||
import { Chapters } from '@/features/chapter/services/Chapters.ts';
|
||||
import { ChapterMangaInfo, ChapterReadInfo, ChapterSourceOrderInfo } from '@/features/chapter/Chapter.types.ts';
|
||||
|
||||
export function ResumeFab({ chapter }: { chapter: ChapterMangaInfo & ChapterSourceOrderInfo & ChapterReadInfo }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { sourceOrder } = chapter;
|
||||
return (
|
||||
<StyledFab
|
||||
component={Link}
|
||||
variant="extended"
|
||||
color="primary"
|
||||
to={Chapters.getReaderUrl(chapter)}
|
||||
state={Chapters.getReaderOpenChapterLocationState(chapter)}
|
||||
>
|
||||
<PlayArrow />
|
||||
{sourceOrder === 1 ? t('global.button.start') : t('global.button.resume')}
|
||||
</StyledFab>
|
||||
);
|
||||
}
|
||||
75
src/features/manga/components/TrackMangaButton.tsx
Normal file
75
src/features/manga/components/TrackMangaButton.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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 { useNavigate } from 'react-router-dom';
|
||||
import SyncIcon from '@mui/icons-material/Sync';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PopupState, { bindDialog, bindTrigger } from 'material-ui-popup-state';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { makeToast } from '@/features/core/utils/Toast.ts';
|
||||
import { TrackManga } from '@/features/tracker/components/TrackManga.tsx';
|
||||
import { Trackers } from '@/features/tracker/services/Trackers.ts';
|
||||
import { CustomButton } from '@/features/core/components/buttons/CustomButton.tsx';
|
||||
import { GetTrackersSettingsQuery, MangaType } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { GET_TRACKERS_SETTINGS } from '@/lib/graphql/queries/TrackerQuery.ts';
|
||||
import { MangaTrackRecordInfo } from '@/features/manga/Manga.types.ts';
|
||||
import { AppRoutes } from '@/features/core/AppRoute.constants.ts';
|
||||
|
||||
export const TrackMangaButton = ({ manga }: { manga: MangaTrackRecordInfo & Pick<MangaType, 'title'> }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const trackerList = requestManager.useGetTrackerList<GetTrackersSettingsQuery>(GET_TRACKERS_SETTINGS);
|
||||
const trackers = trackerList.data?.trackers.nodes ?? [];
|
||||
const mangaTrackers = manga.trackRecords.nodes;
|
||||
|
||||
const loggedInTrackers = Trackers.getLoggedIn(trackers);
|
||||
const trackersInUse = Trackers.getLoggedIn(Trackers.getTrackers(mangaTrackers, trackers));
|
||||
|
||||
const handleClick = (openPopup: () => void) => {
|
||||
if (trackerList.error) {
|
||||
makeToast(t('tracking.error.label.could_not_load_track_info'), 'error', trackerList.error?.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!loggedInTrackers.length) {
|
||||
navigate(AppRoutes.tracker.path);
|
||||
return;
|
||||
}
|
||||
|
||||
openPopup();
|
||||
};
|
||||
|
||||
return (
|
||||
<PopupState variant="dialog" popupId="manga-track-modal">
|
||||
{(popupState) => (
|
||||
<>
|
||||
<CustomButton
|
||||
{...bindTrigger(popupState)}
|
||||
size="medium"
|
||||
disabled={trackerList.loading || !!trackerList.error}
|
||||
onClick={() => handleClick(popupState.open)}
|
||||
variant={trackersInUse.length ? 'contained' : 'outlined'}
|
||||
>
|
||||
{trackersInUse.length ? <CheckIcon /> : <SyncIcon />}
|
||||
{trackersInUse.length
|
||||
? t('manga.button.track.active', { count: trackersInUse.length })
|
||||
: t('manga.button.track.start')}
|
||||
</CustomButton>
|
||||
{popupState.isOpen && (
|
||||
<Dialog {...bindDialog(popupState)} maxWidth="md" fullWidth scroll="paper">
|
||||
<TrackManga manga={manga} />
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PopupState>
|
||||
);
|
||||
};
|
||||
166
src/features/manga/components/cards/MangaCard.tsx
Normal file
166
src/features/manga/components/cards/MangaCard.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* 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 PopupState, { bindMenu } from 'material-ui-popup-state';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useLongPress } from 'use-long-press';
|
||||
import { MangaActionMenuItems, SingleModeProps } from '@/features/manga/components/MangaActionMenuItems.tsx';
|
||||
import { Menu } from '@/features/core/components/menu/Menu.tsx';
|
||||
import { MigrateDialog } from '@/features/migration/components/MigrateDialog.tsx';
|
||||
import { useManageMangaLibraryState } from '@/features/manga/hooks/useManageMangaLibraryState.tsx';
|
||||
import { MangaGridCard } from '@/features/manga/components/cards/MangaGridCard.tsx';
|
||||
import { MangaListCard } from '@/features/manga/components/cards/MangaListCard.tsx';
|
||||
import { MangaCardMode, MangaCardProps } from '@/features/manga/Manga.types.ts';
|
||||
import { ContinueReadingButton } from '@/features/manga/components/ContinueReadingButton.tsx';
|
||||
import { MangaBadges } from '@/features/manga/components/MangaBadges.tsx';
|
||||
import { GridLayout } from '@/features/core/Core.types.ts';
|
||||
import { AppRoutes } from '@/features/core/AppRoute.constants.ts';
|
||||
import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
|
||||
|
||||
const getMangaLinkTo = (
|
||||
mode: MangaCardMode,
|
||||
mangaId: number,
|
||||
sourceId: string | undefined,
|
||||
mangaTitle: string,
|
||||
): string => {
|
||||
switch (mode) {
|
||||
case 'default':
|
||||
case 'source':
|
||||
case 'duplicate':
|
||||
return AppRoutes.manga.path(mangaId);
|
||||
case 'migrate.search':
|
||||
return AppRoutes.migrate.childRoutes.search.path(sourceId ?? '-1', mangaId, mangaTitle);
|
||||
case 'migrate.select':
|
||||
return '';
|
||||
default:
|
||||
throw new Error(`getMangaLinkTo: unexpected MangaCardMode "${mode}"`);
|
||||
}
|
||||
};
|
||||
|
||||
export const MangaCard = memo((props: MangaCardProps) => {
|
||||
const { manga, gridLayout, inLibraryIndicator, selected, handleSelection, mode = 'default' } = props;
|
||||
const { id, firstUnreadChapter, downloadCount, unreadCount } = manga;
|
||||
const {
|
||||
settings: { showContinueReadingButton },
|
||||
} = useMetadataServerSettings();
|
||||
|
||||
const { CategorySelectComponent, updateLibraryState, isInLibrary } = useManageMangaLibraryState(
|
||||
manga,
|
||||
mode === 'source',
|
||||
);
|
||||
|
||||
const mangaLinkTo = getMangaLinkTo(mode, manga.id, manga.sourceId, manga.title);
|
||||
|
||||
const [isMigrateDialogOpen, setIsMigrateDialogOpen] = useState(false);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent | React.TouchEvent, openMenu?: () => void) => {
|
||||
const isDefaultMode = mode === 'default';
|
||||
const isSourceMode = mode === 'source';
|
||||
const isMigrateSelectMode = mode === 'migrate.select';
|
||||
const isSelectionMode = selected !== null;
|
||||
const isLongPress = !!openMenu;
|
||||
|
||||
const shouldHandleClick =
|
||||
isMigrateSelectMode || isSelectionMode || ((isDefaultMode || isSourceMode) && isLongPress);
|
||||
if (!shouldHandleClick) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (isSourceMode) {
|
||||
updateLibraryState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSelectionMode) {
|
||||
handleSelection?.(id, !selected, { selectRange: event.shiftKey });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDefaultMode) {
|
||||
openMenu?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMigrateSelectMode) {
|
||||
setIsMigrateDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[mode, selected, updateLibraryState, handleSelection],
|
||||
);
|
||||
|
||||
const longPressBind = useLongPress(
|
||||
useCallback(
|
||||
(e: any, { context }: any) => {
|
||||
e.shiftKey = true;
|
||||
handleClick(e, context as () => {});
|
||||
},
|
||||
[handleClick],
|
||||
),
|
||||
);
|
||||
|
||||
const MangaCardComponent = useMemo(
|
||||
() => (gridLayout === GridLayout.List ? MangaListCard : MangaGridCard),
|
||||
[gridLayout],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMigrateDialogOpen && (
|
||||
<MigrateDialog mangaIdToMigrateTo={manga.id} onClose={() => setIsMigrateDialogOpen(false)} />
|
||||
)}
|
||||
<PopupState variant="popover" popupId="manga-card-action-menu">
|
||||
{(popupState) => (
|
||||
<>
|
||||
<MangaCardComponent
|
||||
{...props}
|
||||
longPressBind={longPressBind}
|
||||
popupState={popupState}
|
||||
handleClick={handleClick}
|
||||
mangaLinkTo={mangaLinkTo}
|
||||
isInLibrary={isInLibrary}
|
||||
inLibraryIndicator={inLibraryIndicator}
|
||||
continueReadingButton={
|
||||
<ContinueReadingButton
|
||||
showContinueReadingButton={showContinueReadingButton && mode === 'default'}
|
||||
chapter={firstUnreadChapter}
|
||||
mangaLinkTo={mangaLinkTo}
|
||||
/>
|
||||
}
|
||||
mangaBadges={
|
||||
<MangaBadges
|
||||
inLibraryIndicator={inLibraryIndicator}
|
||||
isInLibrary={isInLibrary}
|
||||
unread={unreadCount}
|
||||
downloadCount={downloadCount}
|
||||
updateLibraryState={updateLibraryState}
|
||||
mode={mode}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{!!handleSelection && popupState.isOpen && (
|
||||
<Menu {...bindMenu(popupState)}>
|
||||
{(onClose, setHideMenu) => (
|
||||
<MangaActionMenuItems
|
||||
manga={manga as SingleModeProps['manga']}
|
||||
handleSelection={handleSelection}
|
||||
onClose={onClose}
|
||||
setHideMenu={setHideMenu}
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
)}
|
||||
{CategorySelectComponent}
|
||||
</>
|
||||
)}
|
||||
</PopupState>
|
||||
</>
|
||||
);
|
||||
});
|
||||
213
src/features/manga/components/cards/MangaGridCard.tsx
Normal file
213
src/features/manga/components/cards/MangaGridCard.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* 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 Link from '@mui/material/Link';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { memo, useRef } from 'react';
|
||||
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
|
||||
import { SpinnerImage } from '@/features/core/components/SpinnerImage.tsx';
|
||||
import { MangaOptionButton } from '@/features/manga/components/MangaOptionButton.tsx';
|
||||
import { Mangas } from '@/features/manga/services/Mangas.ts';
|
||||
import { SpecificMangaCardProps } from '@/features/manga/Manga.types.ts';
|
||||
import { TypographyMaxLines } from '@/features/core/components/texts/TypographyMaxLines.tsx';
|
||||
import { MANGA_COVER_ASPECT_RATIO } from '@/features/manga/Manga.constants.ts';
|
||||
import { GridLayout } from '@/features/core/Core.types.ts';
|
||||
import { MediaQuery } from '@/features/core/utils/MediaQuery.tsx';
|
||||
|
||||
const BottomGradient = styled('div')({
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
height: '30%',
|
||||
background: 'linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 100%)',
|
||||
});
|
||||
|
||||
const BottomGradientDoubledDown = styled('div')({
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
height: '20%',
|
||||
background: 'linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 100%)',
|
||||
});
|
||||
|
||||
export const MangaGridCard = memo(
|
||||
({
|
||||
manga,
|
||||
longPressBind,
|
||||
popupState,
|
||||
handleClick,
|
||||
mangaLinkTo,
|
||||
selected,
|
||||
inLibraryIndicator,
|
||||
isInLibrary,
|
||||
gridLayout,
|
||||
handleSelection,
|
||||
continueReadingButton,
|
||||
mangaBadges,
|
||||
mode,
|
||||
}: SpecificMangaCardProps) => {
|
||||
const preventMobileContextMenu = MediaQuery.usePreventMobileContextMenu();
|
||||
const optionButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const { id, title } = manga;
|
||||
|
||||
return (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
{...longPressBind(() => popupState.open(optionButtonRef.current))}
|
||||
onClick={handleClick}
|
||||
to={mangaLinkTo}
|
||||
state={Mangas.createLocationState(manga, mode)}
|
||||
onContextMenu={preventMobileContextMenu}
|
||||
sx={{
|
||||
...MediaQuery.preventMobileContextMenuSx(),
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
m: 0.25,
|
||||
outline: selected ? '4px solid' : undefined,
|
||||
borderRadius: selected ? '1px' : undefined,
|
||||
outlineColor: (theme) => 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',
|
||||
},
|
||||
'&:hover .source-manga-library-state-button': {
|
||||
display: 'inline-flex',
|
||||
},
|
||||
'&:hover .source-manga-library-state-indicator': {
|
||||
display: mode === 'source' ? 'none' : 'flex',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
// force standard aspect ratio of manga covers
|
||||
aspectRatio: MANGA_COVER_ASPECT_RATIO,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<CardActionArea
|
||||
sx={{
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<SpinnerImage
|
||||
alt={title}
|
||||
src={Mangas.getThumbnailUrl(manga)}
|
||||
imgStyle={
|
||||
inLibraryIndicator && isInLibrary
|
||||
? {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
objectFit: 'cover',
|
||||
filter: 'brightness(0.4)',
|
||||
}
|
||||
: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
objectFit: 'cover',
|
||||
}
|
||||
}
|
||||
spinnerStyle={{
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
alignItems: 'start',
|
||||
justifyContent: 'space-between',
|
||||
position: 'absolute',
|
||||
top: (theme) => theme.spacing(1),
|
||||
left: (theme) => theme.spacing(1),
|
||||
right: (theme) => theme.spacing(1),
|
||||
}}
|
||||
>
|
||||
{mangaBadges}
|
||||
<MangaOptionButton
|
||||
ref={optionButtonRef}
|
||||
popupState={popupState}
|
||||
id={id}
|
||||
selected={selected}
|
||||
handleSelection={handleSelection}
|
||||
/>
|
||||
</Stack>
|
||||
<>
|
||||
{gridLayout !== GridLayout.Comfortable && (
|
||||
<>
|
||||
<BottomGradient />
|
||||
<BottomGradientDoubledDown />
|
||||
</>
|
||||
)}
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: gridLayout !== GridLayout.Comfortable ? 'space-between' : 'end',
|
||||
alignItems: 'end',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
p: 1,
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{gridLayout !== GridLayout.Comfortable && (
|
||||
<CustomTooltip title={title} placement="top">
|
||||
<TypographyMaxLines
|
||||
component="h3"
|
||||
sx={{
|
||||
color: 'white',
|
||||
textShadow: '0px 0px 3px #000000',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</TypographyMaxLines>
|
||||
</CustomTooltip>
|
||||
)}
|
||||
{continueReadingButton}
|
||||
</Stack>
|
||||
</>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
{gridLayout === GridLayout.Comfortable && (
|
||||
<Stack sx={{ pb: 1 }}>
|
||||
<CustomTooltip title={title} placement="top">
|
||||
<TypographyMaxLines
|
||||
component="h3"
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
selected ? theme.palette.primary.contrastText : 'text.primary',
|
||||
height: '3rem',
|
||||
pt: 0.5,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</TypographyMaxLines>
|
||||
</CustomTooltip>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
);
|
||||
125
src/features/manga/components/cards/MangaListCard.tsx
Normal file
125
src/features/manga/components/cards/MangaListCard.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* 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 Card from '@mui/material/Card';
|
||||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { memo, useRef } from 'react';
|
||||
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
|
||||
import { TypographyMaxLines } from '@/features/core/components/texts/TypographyMaxLines.tsx';
|
||||
import { SpecificMangaCardProps } from '@/features/manga/Manga.types.ts';
|
||||
import { Mangas } from '@/features/manga/services/Mangas.ts';
|
||||
import { MangaOptionButton } from '@/features/manga/components/MangaOptionButton.tsx';
|
||||
import { ListCardAvatar } from '@/features/core/components/lists/cards/ListCardAvatar.tsx';
|
||||
import { ListCardContent } from '@/features/core/components/lists/cards/ListCardContent';
|
||||
import { MediaQuery } from '@/features/core/utils/MediaQuery.tsx';
|
||||
|
||||
export const MangaListCard = memo(
|
||||
({
|
||||
manga,
|
||||
longPressBind,
|
||||
popupState,
|
||||
handleClick,
|
||||
mangaLinkTo,
|
||||
selected,
|
||||
inLibraryIndicator,
|
||||
isInLibrary,
|
||||
handleSelection,
|
||||
continueReadingButton,
|
||||
mangaBadges,
|
||||
mode,
|
||||
}: SpecificMangaCardProps) => {
|
||||
const preventMobileContextMenu = MediaQuery.usePreventMobileContextMenu();
|
||||
|
||||
const optionButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const { id, title } = manga;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardActionArea
|
||||
component={RouterLink}
|
||||
to={mangaLinkTo}
|
||||
state={Mangas.createLocationState(manga, mode)}
|
||||
onClick={handleClick}
|
||||
{...longPressBind(() => popupState.open(optionButtonRef.current))}
|
||||
onContextMenu={preventMobileContextMenu}
|
||||
sx={{
|
||||
...MediaQuery.preventMobileContextMenuSx(),
|
||||
'@media (hover: hover) and (pointer: fine)': {
|
||||
'&:hover .manga-option-button': {
|
||||
visibility: 'visible',
|
||||
pointerEvents: 'all',
|
||||
},
|
||||
'&:hover .source-manga-library-state-button': {
|
||||
display: 'inline-flex',
|
||||
},
|
||||
'&:hover .source-manga-library-state-indicator': {
|
||||
display: mode === 'source' ? 'none' : 'inline-flex',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListCardContent
|
||||
sx={{
|
||||
justifyContent: 'space-between',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<ListCardAvatar
|
||||
iconUrl={Mangas.getThumbnailUrl(manga)}
|
||||
alt={manga.title}
|
||||
slots={{
|
||||
spinnerImageProps: {
|
||||
imgStyle: {
|
||||
imageRendering: 'pixelated',
|
||||
filter: inLibraryIndicator && isInLibrary ? 'brightness(0.4)' : undefined,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexGrow: 1,
|
||||
width: 'min-content',
|
||||
}}
|
||||
>
|
||||
<CustomTooltip title={title} placement="top">
|
||||
<TypographyMaxLines variant="h6" component="h3">
|
||||
{title}
|
||||
</TypographyMaxLines>
|
||||
</CustomTooltip>
|
||||
</Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{mangaBadges}
|
||||
{continueReadingButton}
|
||||
<MangaOptionButton
|
||||
ref={optionButtonRef}
|
||||
popupState={popupState}
|
||||
id={id}
|
||||
selected={selected}
|
||||
handleSelection={handleSelection}
|
||||
asCheckbox
|
||||
/>
|
||||
</Stack>
|
||||
</ListCardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
);
|
||||
104
src/features/manga/components/details/DescriptionGenre.tsx
Normal file
104
src/features/manga/components/details/DescriptionGenre.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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 { useCallback, useMemo, useState } from 'react';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import { useLocalStorage } from '@/features/core/hooks/useStorage.tsx';
|
||||
import { useResizeObserver } from '@/features/core/hooks/useResizeObserver.tsx';
|
||||
import {
|
||||
MangaDescriptionInfo,
|
||||
MangaGenreInfo,
|
||||
MangaLocationState,
|
||||
MangaSourceIdInfo,
|
||||
} from '@/features/manga/Manga.types.ts';
|
||||
import { SearchLink } from '@/features/manga/components/details/SearchLink.tsx';
|
||||
|
||||
const OPEN_CLOSE_BUTTON_HEIGHT = '35px';
|
||||
const DESCRIPTION_COLLAPSED_SIZE = 75;
|
||||
|
||||
export const DescriptionGenre = ({
|
||||
manga: { description, genre: mangaGenres, sourceId },
|
||||
mode,
|
||||
}: {
|
||||
manga: MangaDescriptionInfo & MangaGenreInfo & MangaSourceIdInfo;
|
||||
mode: MangaLocationState['mode'];
|
||||
}) => {
|
||||
const [descriptionElement, setDescriptionElement] = useState<HTMLSpanElement | null>(null);
|
||||
const [descriptionHeight, setDescriptionHeight] = useState<number>();
|
||||
useResizeObserver(
|
||||
descriptionElement,
|
||||
useCallback(() => setDescriptionHeight(descriptionElement?.clientHeight), [descriptionElement]),
|
||||
);
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useLocalStorage('isDescriptionGenreCollapsed', true);
|
||||
|
||||
const collapsedSize = description
|
||||
? Math.min(DESCRIPTION_COLLAPSED_SIZE, descriptionHeight ?? DESCRIPTION_COLLAPSED_SIZE)
|
||||
: 0;
|
||||
const genres = useMemo(() => mangaGenres.filter(Boolean), [mangaGenres]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{description && (
|
||||
<Stack sx={{ position: 'relative' }}>
|
||||
<Collapse collapsedSize={collapsedSize} in={!isCollapsed}>
|
||||
<Typography
|
||||
ref={setDescriptionElement}
|
||||
sx={{
|
||||
whiteSpace: 'pre-line',
|
||||
textAlign: 'justify',
|
||||
textJustify: 'inter-word',
|
||||
mb: OPEN_CLOSE_BUTTON_HEIGHT,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
</Collapse>
|
||||
<Stack
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
sx={{
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: OPEN_CLOSE_BUTTON_HEIGHT,
|
||||
bottom: 0,
|
||||
background: (theme) =>
|
||||
`linear-gradient(transparent -15px, ${theme.palette.background.default})`,
|
||||
}}
|
||||
>
|
||||
<IconButton sx={{ color: (theme) => (theme.palette.mode === 'light' ? 'black' : 'text') }}>
|
||||
{isCollapsed ? <ExpandMoreIcon /> : <ExpandLessIcon />}
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
<Stack
|
||||
sx={{
|
||||
flexDirection: 'row',
|
||||
flexWrap: isCollapsed ? 'no-wrap' : 'wrap',
|
||||
gap: 1,
|
||||
overflowX: isCollapsed ? 'auto' : null,
|
||||
}}
|
||||
>
|
||||
{genres.map((genre) => (
|
||||
<SearchLink key={genre} query={genre} sourceId={sourceId} mode={mode}>
|
||||
<Chip label={genre} variant="outlined" onClick={() => {}} />
|
||||
</SearchLink>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
297
src/features/manga/components/details/MangaDetails.tsx
Normal file
297
src/features/manga/components/details/MangaDetails.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
/*
|
||||
* 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 FavoriteIcon from '@mui/icons-material/Favorite';
|
||||
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { ComponentProps, ReactNode, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { t as translate } from 'i18next';
|
||||
import Link from '@mui/material/Link';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import ButtonGroup from '@mui/material/ButtonGroup';
|
||||
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
|
||||
import { makeToast } from '@/features/core/utils/Toast.ts';
|
||||
import { Mangas } from '@/features/manga/services/Mangas.ts';
|
||||
import { SpinnerImage } from '@/features/core/components/SpinnerImage.tsx';
|
||||
import { CustomButton } from '@/features/core/components/buttons/CustomButton.tsx';
|
||||
import { TrackMangaButton } from '@/features/manga/components/TrackMangaButton.tsx';
|
||||
import { useManageMangaLibraryState } from '@/features/manga/hooks/useManageMangaLibraryState.tsx';
|
||||
import { Metadata as BaseMetadata } from '@/features/core/components/texts/Metadata.tsx';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import { MangaType, SourceType } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
|
||||
import { MANGA_STATUS_TO_TRANSLATION } from '@/features/manga/Manga.constants.ts';
|
||||
import {
|
||||
MangaArtistInfo,
|
||||
MangaAuthorInfo,
|
||||
MangaDescriptionInfo,
|
||||
MangaGenreInfo,
|
||||
MangaIdInfo,
|
||||
MangaInLibraryInfo,
|
||||
MangaLocationState,
|
||||
MangaSourceIdInfo,
|
||||
MangaStatusInfo,
|
||||
MangaThumbnailInfo,
|
||||
MangaTitleInfo,
|
||||
MangaTrackRecordInfo,
|
||||
} from '@/features/manga/Manga.types.ts';
|
||||
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
|
||||
import { CustomButtonIcon } from '@/features/core/components/buttons/CustomButtonIcon.tsx';
|
||||
import { Sources } from '@/features/source/services/Sources.ts';
|
||||
import { SourceIdInfo } from '@/features/source/Source.types.ts';
|
||||
import { Thumbnail } from '@/features/manga/components/details/Thumbnail.tsx';
|
||||
import { DescriptionGenre } from '@/features/manga/components/details/DescriptionGenre.tsx';
|
||||
import { SearchLink } from '@/features/manga/components/details/SearchLink.tsx';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { IconBrowser } from '@/assets/icons/IconBrowser.tsx';
|
||||
import { IconWebView } from '@/assets/icons/IconWebView.tsx';
|
||||
|
||||
const DetailsWrapper = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(2),
|
||||
padding: theme.spacing(1),
|
||||
[theme.breakpoints.up('md')]: {
|
||||
flexBasis: '40%',
|
||||
height: 'calc(100vh - 64px)',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
}));
|
||||
|
||||
const TopContentWrapper = ({
|
||||
url,
|
||||
mangaThumbnailBackdrop,
|
||||
children,
|
||||
}: {
|
||||
url: string;
|
||||
mangaThumbnailBackdrop: boolean;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<Stack
|
||||
sx={{
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{mangaThumbnailBackdrop && (
|
||||
<>
|
||||
<SpinnerImage
|
||||
spinnerStyle={{ display: 'none' }}
|
||||
imgStyle={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
src={url}
|
||||
alt="Manga Thumbnail"
|
||||
/>
|
||||
<Stack
|
||||
sx={{
|
||||
'&::before': (theme) =>
|
||||
applyStyles(mangaThumbnailBackdrop, {
|
||||
position: 'absolute',
|
||||
display: 'inline-block',
|
||||
content: '""',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: `linear-gradient(to top, ${theme.palette.background.default}, transparent 100%, transparent 1px),linear-gradient(to right, ${theme.palette.background.default}, transparent 50%, transparent 1px),linear-gradient(to bottom, ${theme.palette.background.default}, transparent 50%, transparent 1px),linear-gradient(to left, ${theme.palette.background.default}, transparent 50%, transparent 1px)`,
|
||||
backdropFilter: 'blur(4.5px) brightness(0.75)',
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const ThumbnailMetadataWrapper = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
paddingBottom: theme.spacing(1),
|
||||
}));
|
||||
|
||||
const MetadataContainer = styled('div')(({ theme }) => ({
|
||||
zIndex: 1,
|
||||
marginLeft: theme.spacing(1),
|
||||
}));
|
||||
|
||||
const Metadata = (props: ComponentProps<typeof BaseMetadata>) => <BaseMetadata {...props} />;
|
||||
|
||||
const MangaButtonsContainer = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
gap: theme.spacing(1),
|
||||
}));
|
||||
|
||||
const OpenSourceButton = ({ url }: { url?: string | null }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<CustomTooltip title={t('global.button.open_browser')} disabled={!url}>
|
||||
<CustomButtonIcon
|
||||
size="medium"
|
||||
disabled={!url}
|
||||
component={Link}
|
||||
href={url ?? undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
variant="outlined"
|
||||
>
|
||||
<IconBrowser />
|
||||
</CustomButtonIcon>
|
||||
</CustomTooltip>
|
||||
<CustomTooltip title={t('global.button.open_webview')} disabled={!url}>
|
||||
<CustomButtonIcon
|
||||
size="medium"
|
||||
disabled={!url}
|
||||
component={Link}
|
||||
href={url ? requestManager.getWebviewUrl(url) : undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
variant="outlined"
|
||||
>
|
||||
<IconWebView />
|
||||
</CustomButtonIcon>
|
||||
</CustomTooltip>
|
||||
</ButtonGroup>
|
||||
);
|
||||
};
|
||||
|
||||
function getSourceName(source?: Pick<SourceType, 'id' | 'displayName'> | null): string {
|
||||
if (!source) {
|
||||
return translate('global.label.unknown');
|
||||
}
|
||||
|
||||
if (Sources.isLocalSource(source)) {
|
||||
return translate('source.local_source.title');
|
||||
}
|
||||
|
||||
return source.displayName ?? source.id;
|
||||
}
|
||||
|
||||
const valuesToJoinedSearchLinks = (
|
||||
values: string[] | undefined,
|
||||
sourceId: SourceIdInfo['id'] | undefined,
|
||||
mode: MangaLocationState['mode'],
|
||||
) =>
|
||||
values
|
||||
?.map((value) => <SearchLink key={value} query={value} sourceId={sourceId} mode={mode} />)
|
||||
.reduce((acc, valueLink) => (
|
||||
<>
|
||||
{acc}, {valueLink}
|
||||
</>
|
||||
));
|
||||
|
||||
export const MangaDetails = ({
|
||||
manga,
|
||||
mode,
|
||||
}: {
|
||||
manga: Pick<MangaType, 'realUrl'> &
|
||||
MangaIdInfo &
|
||||
MangaTitleInfo &
|
||||
MangaStatusInfo &
|
||||
MangaInLibraryInfo &
|
||||
MangaAuthorInfo &
|
||||
MangaArtistInfo &
|
||||
MangaDescriptionInfo &
|
||||
MangaGenreInfo &
|
||||
MangaThumbnailInfo &
|
||||
MangaSourceIdInfo &
|
||||
MangaTrackRecordInfo & {
|
||||
source?: Pick<SourceType, 'id' | 'displayName'> | null;
|
||||
};
|
||||
mode: MangaLocationState['mode'];
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
settings: { mangaThumbnailBackdrop, mangaDynamicColorSchemes },
|
||||
} = useMetadataServerSettings();
|
||||
|
||||
useEffect(() => {
|
||||
if (!manga.source) {
|
||||
makeToast(translate('source.error.label.source_not_found'), 'error');
|
||||
}
|
||||
}, [manga.source]);
|
||||
|
||||
const { CategorySelectComponent, updateLibraryState } = useManageMangaLibraryState(manga);
|
||||
|
||||
const copyTitle = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(manga.title);
|
||||
makeToast(t('global.label.copied_clipboard'), 'info');
|
||||
} catch (e) {
|
||||
defaultPromiseErrorHandler('MangaDetails::copyTitleLongPress')(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailsWrapper>
|
||||
<TopContentWrapper url={Mangas.getThumbnailUrl(manga)} mangaThumbnailBackdrop={mangaThumbnailBackdrop}>
|
||||
<ThumbnailMetadataWrapper>
|
||||
<Thumbnail manga={manga} mangaDynamicColorSchemes={mangaDynamicColorSchemes} />
|
||||
<MetadataContainer>
|
||||
<Stack sx={{ flexDirection: 'row', gap: 1, alignItems: 'flex-start', mb: 1 }}>
|
||||
<SearchLink query={manga.title} sourceId={manga.sourceId} mode="source.global-search">
|
||||
<Typography variant="h5" component="h2" sx={{ wordBreak: 'break-word' }}>
|
||||
{manga.title}
|
||||
</Typography>
|
||||
</SearchLink>
|
||||
<CustomTooltip title={t('global.button.copy')}>
|
||||
<IconButton onClick={copyTitle} color="inherit">
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
</Stack>
|
||||
{manga.author && (
|
||||
<Metadata
|
||||
title={t('manga.label.author')}
|
||||
value={valuesToJoinedSearchLinks(Mangas.getAuthors(manga), manga.source?.id, mode)}
|
||||
/>
|
||||
)}
|
||||
{manga.artist && (
|
||||
<Metadata
|
||||
title={t('manga.label.artist')}
|
||||
value={valuesToJoinedSearchLinks(Mangas.getArtists(manga), manga.source?.id, mode)}
|
||||
/>
|
||||
)}
|
||||
<Metadata
|
||||
title={t('manga.label.status')}
|
||||
value={t(MANGA_STATUS_TO_TRANSLATION[manga.status])}
|
||||
/>
|
||||
<Metadata title={t('source.title_one')} value={getSourceName(manga.source)} />
|
||||
</MetadataContainer>
|
||||
</ThumbnailMetadataWrapper>
|
||||
<MangaButtonsContainer>
|
||||
<CustomButton
|
||||
size="medium"
|
||||
onClick={updateLibraryState}
|
||||
variant={manga.inLibrary ? 'contained' : 'outlined'}
|
||||
>
|
||||
{manga.inLibrary ? <FavoriteIcon /> : <FavoriteBorderIcon />}
|
||||
{manga.inLibrary ? t('manga.button.in_library') : t('manga.button.add_to_library')}
|
||||
</CustomButton>
|
||||
<TrackMangaButton manga={manga} />
|
||||
<OpenSourceButton url={manga.realUrl} />
|
||||
</MangaButtonsContainer>
|
||||
</TopContentWrapper>
|
||||
<DescriptionGenre manga={manga} mode={mode} />
|
||||
</DetailsWrapper>
|
||||
{CategorySelectComponent}
|
||||
</>
|
||||
);
|
||||
};
|
||||
45
src/features/manga/components/details/SearchLink.tsx
Normal file
45
src/features/manga/components/details/SearchLink.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 { ReactNode } from 'react';
|
||||
import Link from '@mui/material/Link';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { MangaLocationState } from '@/features/manga/Manga.types.ts';
|
||||
import { SourceIdInfo } from '@/features/source/Source.types.ts';
|
||||
import { AppRoutes } from '@/features/core/AppRoute.constants.ts';
|
||||
|
||||
export const SearchLink = ({
|
||||
query,
|
||||
sourceId,
|
||||
mode,
|
||||
children,
|
||||
}: {
|
||||
query: string;
|
||||
sourceId: SourceIdInfo['id'] | undefined;
|
||||
mode: MangaLocationState['mode'] | 'source.global-search';
|
||||
children?: ReactNode;
|
||||
}) => {
|
||||
const link = (() => {
|
||||
const isSourceMode = mode === 'source' && sourceId !== undefined;
|
||||
if (isSourceMode) {
|
||||
return AppRoutes.sources.childRoutes.browse.path(sourceId, query);
|
||||
}
|
||||
|
||||
if (mode === 'source.global-search') {
|
||||
return AppRoutes.sources.childRoutes.searchAll.path(query);
|
||||
}
|
||||
|
||||
return AppRoutes.library.path(undefined, query);
|
||||
})();
|
||||
|
||||
return (
|
||||
<Link component={RouterLink} to={link} sx={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
{children ?? query}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
146
src/features/manga/components/details/Thumbnail.tsx
Normal file
146
src/features/manga/components/details/Thumbnail.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* 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 { useTheme } from '@mui/material/styles';
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import OpenInFullIcon from '@mui/icons-material/OpenInFull';
|
||||
import Modal from '@mui/material/Modal';
|
||||
import { bindPopover, bindTrigger, usePopupState } from 'material-ui-popup-state/hooks';
|
||||
import { Vibrant } from 'node-vibrant/browser';
|
||||
import { FastAverageColor } from 'fast-average-color';
|
||||
import { Mangas } from '@/features/manga/services/Mangas.ts';
|
||||
import { SpinnerImage } from '@/features/core/components/SpinnerImage.tsx';
|
||||
import { MANGA_COVER_ASPECT_RATIO } from '@/features/manga/Manga.constants.ts';
|
||||
import { MangaThumbnailInfo } from '@/features/manga/Manga.types.ts';
|
||||
import { TAppThemeContext, useAppThemeContext } from '@/features/theme/contexts/AppThemeContext.tsx';
|
||||
|
||||
export const Thumbnail = ({
|
||||
manga,
|
||||
mangaDynamicColorSchemes,
|
||||
}: {
|
||||
manga: Partial<MangaThumbnailInfo>;
|
||||
mangaDynamicColorSchemes: boolean;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { setDynamicColor } = useAppThemeContext();
|
||||
|
||||
const popupState = usePopupState({ variant: 'popover', popupId: 'manga-thumbnail-fullscreen' });
|
||||
|
||||
const [isImageReady, setIsImageReady] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!mangaDynamicColorSchemes) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.src = Mangas.getThumbnailUrl(manga);
|
||||
|
||||
img.onload = () => {
|
||||
const isLargeImage = img.width > 600 && img.height > 600;
|
||||
|
||||
Promise.all([
|
||||
Vibrant.from(img).getPalette(),
|
||||
new FastAverageColor().getColor(img, {
|
||||
algorithm: 'dominant',
|
||||
mode: isLargeImage ? 'speed' : 'precision',
|
||||
ignoredColor: [
|
||||
[255, 255, 255, 255, 75],
|
||||
[0, 0, 0, 255, 75],
|
||||
],
|
||||
}),
|
||||
]).then(([palette, averageColor]) => {
|
||||
if (
|
||||
!palette.Vibrant ||
|
||||
!palette.DarkVibrant ||
|
||||
!palette.LightVibrant ||
|
||||
!palette.LightMuted ||
|
||||
!palette.Muted ||
|
||||
!palette.DarkMuted
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDynamicColor({
|
||||
...palette,
|
||||
average: averageColor,
|
||||
} as TAppThemeContext['dynamicColor']);
|
||||
});
|
||||
};
|
||||
|
||||
return () => {
|
||||
setDynamicColor(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'background.paper',
|
||||
width: '150px',
|
||||
maxHeight: 'fit-content',
|
||||
aspectRatio: MANGA_COVER_ASPECT_RATIO,
|
||||
flexShrink: 0,
|
||||
flexGrow: 0,
|
||||
[theme.breakpoints.up('lg')]: {
|
||||
width: '200px',
|
||||
},
|
||||
[theme.breakpoints.up('xl')]: {
|
||||
width: '300px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SpinnerImage
|
||||
src={Mangas.getThumbnailUrl(manga)}
|
||||
alt="Manga Thumbnail"
|
||||
onLoad={() => setIsImageReady(true)}
|
||||
imgStyle={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
{isImageReady && (
|
||||
<Stack
|
||||
{...bindTrigger(popupState)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
opacity: 0,
|
||||
'&:hover': {
|
||||
background: 'rgba(0, 0, 0, 0.4)',
|
||||
cursor: 'pointer',
|
||||
opacity: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<OpenInFullIcon fontSize="large" color="primary" />
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
<Modal {...bindPopover(popupState)} sx={{ outline: 0 }}>
|
||||
<Stack
|
||||
onClick={() => popupState.close()}
|
||||
sx={{ height: '100vh', p: 2, outline: 0, justifyContent: 'center', alignItems: 'center' }}
|
||||
>
|
||||
<SpinnerImage
|
||||
src={Mangas.getThumbnailUrl(manga)}
|
||||
alt="Manga Thumbnail"
|
||||
imgStyle={{ height: '100%', width: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
178
src/features/manga/hooks/useManageMangaLibraryState.tsx
Normal file
178
src/features/manga/hooks/useManageMangaLibraryState.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* 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 { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import gql from 'graphql-tag';
|
||||
import { useCategorySelect } from '@/features/category/hooks/useCategorySelect.tsx';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { makeToast } from '@/features/core/utils/Toast.ts';
|
||||
import { getMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
|
||||
import { Categories } from '@/features/category/services/Categories.ts';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import { Mangas } from '@/features/manga/services/Mangas.ts';
|
||||
import { awaitConfirmation } from '@/features/core/utils/AwaitableDialog.tsx';
|
||||
import { GetCategoriesBaseQuery, GetCategoriesBaseQueryVariables, MangaType } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { GET_CATEGORIES_BASE } from '@/lib/graphql/queries/CategoryQuery.ts';
|
||||
import { AppRoutes } from '@/features/core/AppRoute.constants.ts';
|
||||
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
||||
|
||||
export const useManageMangaLibraryState = (
|
||||
manga: Pick<MangaType, 'id' | 'title'> & Partial<Pick<MangaType, 'inLibrary'>>,
|
||||
confirmRemoval: boolean = false,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isInLibrary, setIsInLibrary] = useState(!!manga.inLibrary);
|
||||
|
||||
const addToLibrary = useCallback(
|
||||
(didSubmit: boolean, addToCategories: number[] = [], removeFromCategories: number[] = []) => {
|
||||
if (!didSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestManager
|
||||
.updateManga(manga.id, {
|
||||
updateManga: { inLibrary: true },
|
||||
updateMangaCategories: { addToCategories, removeFromCategories },
|
||||
})
|
||||
.response.then(() => makeToast(t('library.info.label.added_to_library'), 'success'))
|
||||
.then(() => setIsInLibrary(true))
|
||||
.catch((e) => {
|
||||
makeToast(t('library.error.label.add_to_library'), 'error', getErrorMessage(e));
|
||||
});
|
||||
},
|
||||
[manga.id],
|
||||
);
|
||||
|
||||
const removeFromLibrary = useCallback(async () => {
|
||||
if (confirmRemoval) {
|
||||
await awaitConfirmation({
|
||||
title: t('global.label.are_you_sure'),
|
||||
message: t('manga.action.library.remove.dialog.label.message', { title: manga.title }),
|
||||
actions: {
|
||||
confirm: { title: t('global.button.remove') },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await Mangas.removeFromLibrary([manga.id], true);
|
||||
setIsInLibrary(false);
|
||||
}, [manga.id, confirmRemoval]);
|
||||
|
||||
const { openCategorySelect, CategorySelectComponent } = useCategorySelect({
|
||||
mangaId: manga.id,
|
||||
addToLibrary: true,
|
||||
onClose: addToLibrary,
|
||||
});
|
||||
|
||||
const updateLibraryState = useCallback(() => {
|
||||
const update = async () => {
|
||||
if (isInLibrary) {
|
||||
removeFromLibrary().catch(
|
||||
defaultPromiseErrorHandler('useManageMangaLibraryState::updateLibraryState::removeFromLibrary'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let showAddToLibraryCategorySelectDialog: boolean;
|
||||
try {
|
||||
showAddToLibraryCategorySelectDialog = (await getMetadataServerSettings())
|
||||
.showAddToLibraryCategorySelectDialog;
|
||||
} catch (e) {
|
||||
makeToast(t('global.error.label.failed_to_load_data'), 'error', getErrorMessage(e));
|
||||
return;
|
||||
}
|
||||
|
||||
let categories: Awaited<
|
||||
ReturnType<
|
||||
typeof requestManager.getCategories<GetCategoriesBaseQuery, GetCategoriesBaseQueryVariables>
|
||||
>['response']
|
||||
>;
|
||||
try {
|
||||
categories = await requestManager.getCategories<
|
||||
GetCategoriesBaseQuery,
|
||||
GetCategoriesBaseQueryVariables
|
||||
>(GET_CATEGORIES_BASE).response;
|
||||
} catch (e) {
|
||||
makeToast(t('category.error.label.request_failure'), 'error', getErrorMessage(e));
|
||||
return;
|
||||
}
|
||||
const userCreatedCategories = Categories.getUserCreated(categories.data.categories.nodes);
|
||||
|
||||
let duplicatedLibraryMangas:
|
||||
| Awaited<ReturnType<typeof Mangas.getDuplicateLibraryMangas>['response']>
|
||||
| undefined;
|
||||
try {
|
||||
duplicatedLibraryMangas = await Mangas.getDuplicateLibraryMangas(manga.title).response;
|
||||
} catch (e) {
|
||||
await awaitConfirmation({
|
||||
title: t('global.error.label.failed_to_load_data'),
|
||||
message: t('manga.action.library.add.dialog.duplicate.label.failure', {
|
||||
error: getErrorMessage(e),
|
||||
}),
|
||||
actions: {
|
||||
extra: { show: true, title: t('global.button.retry'), contain: true },
|
||||
confirm: { title: t('global.button.add') },
|
||||
},
|
||||
onExtra: () =>
|
||||
update().catch(
|
||||
defaultPromiseErrorHandler('useManageMangaLibraryState::update: retry duplicate check'),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const doDuplicatesExist = duplicatedLibraryMangas?.data.mangas.totalCount;
|
||||
if (doDuplicatesExist) {
|
||||
await awaitConfirmation({
|
||||
title: t('global.label.are_you_sure'),
|
||||
message: t('manga.action.library.add.dialog.duplicate.label.info'),
|
||||
actions: {
|
||||
extra: { show: true, title: t('migrate.dialog.action.button.show_entry'), contain: true },
|
||||
confirm: { title: t('global.button.add') },
|
||||
},
|
||||
onExtra: () => navigate(AppRoutes.manga.path(duplicatedLibraryMangas!.data.mangas.nodes[0].id)),
|
||||
});
|
||||
}
|
||||
|
||||
const showCategorySelectDialog = showAddToLibraryCategorySelectDialog && !!userCreatedCategories.length;
|
||||
if (!showCategorySelectDialog) {
|
||||
addToLibrary(true, Categories.getIds(Categories.getDefaults(userCreatedCategories!)));
|
||||
return;
|
||||
}
|
||||
|
||||
openCategorySelect(true);
|
||||
};
|
||||
|
||||
update().catch(defaultPromiseErrorHandler('useManageMangaLibraryState::updateLibraryState'));
|
||||
}, [isInLibrary, removeFromLibrary, addToLibrary]);
|
||||
|
||||
return {
|
||||
CategorySelectComponent,
|
||||
updateLibraryState,
|
||||
/**
|
||||
* In case of browsing the source, the data has to be fetched via a mutation.
|
||||
* Thus, the source browse data never has the updated in library state unless it has to rerender, which does not get
|
||||
* triggered by updating the manga in this hook.
|
||||
*
|
||||
* To work around this issue, the currently known in library state gets returned here
|
||||
*/
|
||||
isInLibrary:
|
||||
Mangas.getFromCache(
|
||||
manga.id,
|
||||
gql`
|
||||
fragment MangaInLibraryState on MangaType {
|
||||
inLibrary
|
||||
}
|
||||
`,
|
||||
'MangaInLibraryState',
|
||||
)?.inLibrary ?? isInLibrary,
|
||||
};
|
||||
};
|
||||
36
src/features/manga/hooks/useRefreshManga.ts
Normal file
36
src/features/manga/hooks/useRefreshManga.ts
Normal file
@@ -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 { useCallback, useState } from 'react';
|
||||
import { ApolloError } from '@apollo/client';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { baseCleanup } from '@/util/Strings.ts';
|
||||
|
||||
export const useRefreshManga = (mangaId: string) => {
|
||||
const [fetchingOnline, setFetchingOnline] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setFetchingOnline(true);
|
||||
setError(null);
|
||||
await Promise.all([
|
||||
requestManager.getMangaFetch(mangaId, { awaitRefetchQueries: true }).response,
|
||||
requestManager.getMangaChaptersFetch(mangaId, { awaitRefetchQueries: true }).response,
|
||||
])
|
||||
.catch((e) => {
|
||||
if (e instanceof ApolloError && baseCleanup(e.message) === 'no chapters found') {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(e);
|
||||
})
|
||||
.finally(() => setFetchingOnline(false));
|
||||
}, [mangaId]);
|
||||
|
||||
return [handleRefresh, { loading: fetchingOnline, error }] as const;
|
||||
};
|
||||
112
src/features/manga/screens/Manga.tsx
Normal file
112
src/features/manga/screens/Manga.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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 Warning from '@mui/icons-material/Warning';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Box from '@mui/material/Box';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { isNetworkRequestInFlight } from '@apollo/client/core/networkStatus';
|
||||
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { ChapterList } from '@/features/chapter/components/ChapterList.tsx';
|
||||
import { useRefreshManga } from '@/features/manga/hooks/useRefreshManga.ts';
|
||||
import { MangaDetails } from '@/features/manga/components/details/MangaDetails.tsx';
|
||||
import { MangaToolbarMenu } from '@/features/manga/components/MangaToolbarMenu.tsx';
|
||||
import { EmptyViewAbsoluteCentered } from '@/features/core/components/feedback/EmptyViewAbsoluteCentered.tsx';
|
||||
import { LoadingPlaceholder } from '@/features/core/components/feedback/LoadingPlaceholder.tsx';
|
||||
import { GetMangaScreenQuery } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { GET_MANGA_SCREEN } from '@/lib/graphql/queries/MangaQuery.ts';
|
||||
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
||||
import { useAppTitleAndAction } from '@/features/navigation-bar/hooks/useAppTitleAndAction.ts';
|
||||
import { MangaLocationState } from '@/features/manga/Manga.types.ts';
|
||||
|
||||
export const Manga: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { mode } = useLocation<MangaLocationState>().state ?? {};
|
||||
|
||||
const autofetchedRef = useRef(false);
|
||||
|
||||
const {
|
||||
data,
|
||||
error: mangaError,
|
||||
loading: isLoading,
|
||||
networkStatus,
|
||||
refetch,
|
||||
} = requestManager.useGetManga<GetMangaScreenQuery>(GET_MANGA_SCREEN, id);
|
||||
const isValidating = isNetworkRequestInFlight(networkStatus);
|
||||
const manga = data?.manga;
|
||||
|
||||
const [refresh, { loading: refreshing, error: refreshError }] = useRefreshManga(id);
|
||||
|
||||
const error = mangaError ?? refreshError;
|
||||
|
||||
useEffect(() => {
|
||||
if (manga == null) return;
|
||||
|
||||
const doFetch = !autofetchedRef.current && !manga.initialized;
|
||||
if (doFetch) {
|
||||
autofetchedRef.current = true;
|
||||
refresh();
|
||||
}
|
||||
}, [manga]);
|
||||
|
||||
useAppTitleAndAction(
|
||||
manga?.title ?? t('manga.title_one'),
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{error && !isValidating && !refreshing && (
|
||||
<CustomTooltip
|
||||
title={
|
||||
<>
|
||||
{t('manga.error.label.request_failure')}
|
||||
<br />
|
||||
{getErrorMessage(error)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButton onClick={() => refetch()}>
|
||||
<Warning color="error" />
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
)}
|
||||
{manga && (refreshing || isValidating) && (
|
||||
<IconButton disabled>
|
||||
<CircularProgress size={16} />
|
||||
</IconButton>
|
||||
)}
|
||||
{manga && <MangaToolbarMenu manga={manga} onRefresh={refresh} refreshing={refreshing} />}
|
||||
</Stack>,
|
||||
[t, error, isValidating, refreshing, manga, refresh],
|
||||
);
|
||||
|
||||
if (error && !manga) {
|
||||
return (
|
||||
<EmptyViewAbsoluteCentered
|
||||
message={t('manga.error.label.request_failure')}
|
||||
messageExtra={getErrorMessage(error)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box sx={{ display: { md: 'flex' }, overflow: 'hidden' }}>
|
||||
{isLoading && <LoadingPlaceholder />}
|
||||
|
||||
{manga && <MangaDetails manga={manga} mode={mode} />}
|
||||
{manga && <ChapterList manga={manga} isRefreshing={refreshing} />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
82
src/features/manga/services/MangaMetadata.ts
Normal file
82
src/features/manga/services/MangaMetadata.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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 { useEffect, useMemo } from 'react';
|
||||
import { DEFAULT_CHAPTER_OPTIONS } from '@/features/chapter/Chapter.constants.ts';
|
||||
import { getMetadataFrom } from '@/features/metadata/services/MetadataReader.ts';
|
||||
import { MangaIdInfo, MangaMetadata, MangaMetadataKeys } from '@/features/manga/Manga.types.ts';
|
||||
import {
|
||||
AllowedMetadataValueTypes,
|
||||
GqlMetaHolder,
|
||||
Metadata,
|
||||
MetadataHolder,
|
||||
} from '@/features/metadata/Metadata.types.ts';
|
||||
import { convertFromGqlMeta } from '@/features/metadata/services/MetadataConverter.ts';
|
||||
import { requestUpdateMangaMetadata } from '@/features/metadata/services/MetadataUpdater.ts';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
|
||||
const DEFAULT_MANGA_METADATA: MangaMetadata = {
|
||||
...DEFAULT_CHAPTER_OPTIONS,
|
||||
};
|
||||
|
||||
const convertAppMetadataToGqlMetadata = (
|
||||
metadata: Partial<MangaMetadata>,
|
||||
): Metadata<string, AllowedMetadataValueTypes> => ({
|
||||
...metadata,
|
||||
excludedScanlators: JSON.stringify(metadata.excludedScanlators),
|
||||
});
|
||||
|
||||
const getMangaMetadataWithDefaultValueFallback = (
|
||||
meta: MangaIdInfo & MetadataHolder,
|
||||
defaultMetadata: MangaMetadata = DEFAULT_MANGA_METADATA,
|
||||
useEffectFn?: typeof useEffect,
|
||||
): MangaMetadata => getMetadataFrom('manga', meta, defaultMetadata, undefined, useEffectFn);
|
||||
|
||||
const getMetadata = (
|
||||
metaHolder: MangaIdInfo & GqlMetaHolder,
|
||||
defaultMetadata?: MangaMetadata,
|
||||
useEffectFn?: typeof useEffect,
|
||||
) =>
|
||||
getMangaMetadataWithDefaultValueFallback(
|
||||
{ ...metaHolder, meta: convertFromGqlMeta(metaHolder.meta) },
|
||||
defaultMetadata,
|
||||
useEffectFn,
|
||||
);
|
||||
|
||||
export const getMangaMetadata = (
|
||||
metaHolder: MangaIdInfo & GqlMetaHolder,
|
||||
defaultMetadata?: MangaMetadata,
|
||||
): MangaMetadata => getMetadata(metaHolder, defaultMetadata);
|
||||
|
||||
export const useGetMangaMetadata = (
|
||||
metaHolder: MangaIdInfo & GqlMetaHolder,
|
||||
defaultMetadata?: MangaMetadata,
|
||||
): MangaMetadata => {
|
||||
const metadata = getMetadata(metaHolder, defaultMetadata, useEffect);
|
||||
return useMemo(() => metadata, [metaHolder, defaultMetadata]);
|
||||
};
|
||||
|
||||
export const updateMangaMetadata = async <
|
||||
MetadataKeys extends MangaMetadataKeys = MangaMetadataKeys,
|
||||
MetadataKey extends MetadataKeys = MetadataKeys,
|
||||
>(
|
||||
manga: MangaIdInfo & GqlMetaHolder,
|
||||
metadataKey: MetadataKey,
|
||||
value: MangaMetadata[MetadataKey],
|
||||
): Promise<void[]> =>
|
||||
requestUpdateMangaMetadata(manga, [
|
||||
[metadataKey, convertAppMetadataToGqlMetadata({ [metadataKey]: value })[metadataKey]],
|
||||
]);
|
||||
|
||||
export const createUpdateMangaMetadata =
|
||||
<Settings extends MangaMetadataKeys>(
|
||||
manga: MangaIdInfo & GqlMetaHolder,
|
||||
handleError: (error: any) => void = defaultPromiseErrorHandler('createUpdateMangaMetadata'),
|
||||
): ((...args: OmitFirst<Parameters<typeof updateMangaMetadata<Settings>>>) => Promise<void | void[]>) =>
|
||||
(metadataKey, value) =>
|
||||
updateMangaMetadata(manga, metadataKey, value).catch(handleError);
|
||||
693
src/features/manga/services/Mangas.ts
Normal file
693
src/features/manga/services/Mangas.ts
Normal file
@@ -0,0 +1,693 @@
|
||||
/*
|
||||
* 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 i18next, { t as translate } from 'i18next';
|
||||
import { DocumentNode, Unmasked } from '@apollo/client/core';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import {
|
||||
ChapterConditionInput,
|
||||
GetMangasBaseQuery,
|
||||
GetMangasBaseQueryVariables,
|
||||
GetMangasChapterIdsWithStateQuery,
|
||||
GetMangaToMigrateQuery,
|
||||
GetMangaToMigrateToFetchMutation,
|
||||
MangaBaseFieldsFragment,
|
||||
UpdateMangaCategoriesPatchInput,
|
||||
} from '@/lib/graphql/generated/graphql.ts';
|
||||
import { Chapters } from '@/features/chapter/services/Chapters.ts';
|
||||
import { makeToast } from '@/features/core/utils/Toast.ts';
|
||||
import { getMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
|
||||
import { GET_MANGAS_BASE } from '@/lib/graphql/queries/MangaQuery.ts';
|
||||
import { MANGA_BASE_FIELDS } from '@/lib/graphql/fragments/MangaFragments.ts';
|
||||
import { MetadataMigrationSettings } from '@/features/migration/Migration.types.ts';
|
||||
import {
|
||||
MangaAction,
|
||||
MangaArtistInfo,
|
||||
MangaAuthorInfo,
|
||||
MangaCardMode,
|
||||
MangaDownloadInfo,
|
||||
MangaGenreInfo,
|
||||
MangaIdInfo,
|
||||
MangaLocationState,
|
||||
MangaSourceLngInfo,
|
||||
MangaSourceNameInfo,
|
||||
MangaThumbnailInfo,
|
||||
MangaTitleInfo,
|
||||
MangaType,
|
||||
MangaUnreadInfo,
|
||||
MigrateMode,
|
||||
} from '@/features/manga/Manga.types.ts';
|
||||
import {
|
||||
MANGA_ACTION_TO_CONFIRMATION_REQUIRED,
|
||||
MANGA_ACTION_TO_TRANSLATION,
|
||||
MANGA_TAGS_BY_MANGA_TYPE,
|
||||
SOURCES_BY_MANGA_TYPE,
|
||||
} from '@/features/manga/Manga.constants.ts';
|
||||
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
||||
import { awaitConfirmation } from '@/features/core/utils/AwaitableDialog.tsx';
|
||||
import { assertIsDefined } from '@/Asserts.ts';
|
||||
|
||||
type MangaToMigrate = NonNullable<GetMangaToMigrateQuery['manga']>;
|
||||
type MangaToMigrateTo = NonNullable<GetMangaToMigrateToFetchMutation['fetchManga']>['manga'];
|
||||
|
||||
type DownloadChaptersOptions = {
|
||||
size?: number;
|
||||
onlyUnread?: boolean;
|
||||
downloadAhead?: boolean;
|
||||
};
|
||||
type MarkAsReadOptions = { wasManuallyMarkedAsRead: boolean };
|
||||
type ChangeCategoriesOptions = { changeCategoriesPatch: UpdateMangaCategoriesPatchInput };
|
||||
type MigrateOptions = {
|
||||
mangaIdToMigrateTo: number;
|
||||
mode: MigrateMode;
|
||||
} & Partial<MetadataMigrationSettings>;
|
||||
|
||||
type MarkAsReadActionOption = MarkAsReadOptions &
|
||||
PropertiesNever<ChangeCategoriesOptions> &
|
||||
PropertiesNever<MigrateOptions> &
|
||||
PropertiesNever<DownloadChaptersOptions>;
|
||||
type ChangeCategoriesActionOption = PropertiesNever<MarkAsReadOptions> &
|
||||
ChangeCategoriesOptions &
|
||||
PropertiesNever<MigrateOptions> &
|
||||
PropertiesNever<DownloadChaptersOptions>;
|
||||
type MigrateActionOption = PropertiesNever<MarkAsReadOptions> &
|
||||
PropertiesNever<ChangeCategoriesOptions> &
|
||||
MigrateOptions &
|
||||
PropertiesNever<DownloadChaptersOptions>;
|
||||
type DownloadActionOption = PropertiesNever<MarkAsReadOptions> &
|
||||
PropertiesNever<ChangeCategoriesOptions> &
|
||||
PropertiesNever<MigrateOptions> &
|
||||
DownloadChaptersOptions;
|
||||
type DefaultActionOption = Partial<MarkAsReadOptions> &
|
||||
Partial<ChangeCategoriesOptions> &
|
||||
Partial<MigrateOptions> &
|
||||
Partial<DownloadChaptersOptions>;
|
||||
|
||||
type PerformActionOptions<Action extends MangaAction> = Action extends 'mark_as_read'
|
||||
? MarkAsReadActionOption
|
||||
: Action extends 'change_categories'
|
||||
? ChangeCategoriesActionOption
|
||||
: Action extends 'migrate'
|
||||
? MigrateActionOption
|
||||
: Action extends 'download'
|
||||
? DownloadActionOption
|
||||
: DefaultActionOption;
|
||||
|
||||
type MigrateAction = { copy: () => Promise<unknown>[]; cleanup: () => Promise<unknown>[] };
|
||||
type MigrateActionCreator = () => MigrateAction;
|
||||
|
||||
const ARTIST_AUTHOR_SEPARATOR_REGEX = /\s*[,|、]\s*/;
|
||||
|
||||
export class Mangas {
|
||||
static getIds(mangas: MangaIdInfo[]): number[] {
|
||||
return mangas.map((manga) => manga.id);
|
||||
}
|
||||
|
||||
static getFromCache<T = MangaBaseFieldsFragment>(
|
||||
id: MangaIdInfo['id'],
|
||||
fragment: DocumentNode = MANGA_BASE_FIELDS,
|
||||
fragmentName: string = 'MANGA_BASE_FIELDS',
|
||||
): Unmasked<T> | null {
|
||||
return requestManager.graphQLClient.client.cache.readFragment<T>({
|
||||
id: requestManager.graphQLClient.client.cache.identify({
|
||||
__typename: 'MangaType',
|
||||
id,
|
||||
}),
|
||||
fragment,
|
||||
fragmentName,
|
||||
});
|
||||
}
|
||||
|
||||
static isNotDownloaded({ downloadCount }: MangaDownloadInfo): boolean {
|
||||
return downloadCount === 0;
|
||||
}
|
||||
|
||||
static getNotDownloaded<Mangas extends MangaDownloadInfo>(mangas: Mangas[]): Mangas[] {
|
||||
return mangas.filter(Mangas.isNotDownloaded);
|
||||
}
|
||||
|
||||
static isFullyDownloaded({ downloadCount, chapters: { totalCount } }: MangaDownloadInfo): boolean {
|
||||
return downloadCount === totalCount;
|
||||
}
|
||||
|
||||
static getFullyDownloaded<Mangas extends MangaDownloadInfo>(mangas: Mangas[]): Mangas[] {
|
||||
return mangas.filter(Mangas.isFullyDownloaded);
|
||||
}
|
||||
|
||||
static isPartiallyDownloaded(manga: MangaDownloadInfo): boolean {
|
||||
return !Mangas.isNotDownloaded(manga) && !Mangas.isFullyDownloaded(manga);
|
||||
}
|
||||
|
||||
static getPartiallyDownloaded<Mangas extends MangaDownloadInfo>(mangas: Mangas[]): Mangas[] {
|
||||
return mangas.filter(Mangas.isPartiallyDownloaded);
|
||||
}
|
||||
|
||||
static isUnread({ unreadCount, chapters: { totalCount } }: MangaUnreadInfo): boolean {
|
||||
return unreadCount === totalCount;
|
||||
}
|
||||
|
||||
static getUnread<Mangas extends MangaUnreadInfo>(mangas: Mangas[]): Mangas[] {
|
||||
return mangas.filter(Mangas.isUnread);
|
||||
}
|
||||
|
||||
static isFullyRead({ unreadCount }: MangaUnreadInfo): boolean {
|
||||
return unreadCount === 0;
|
||||
}
|
||||
|
||||
static getFullyRead<Mangas extends MangaUnreadInfo>(mangas: Mangas[]): Mangas[] {
|
||||
return mangas.filter(Mangas.isFullyRead);
|
||||
}
|
||||
|
||||
static isPartiallyRead(manga: MangaUnreadInfo): boolean {
|
||||
return !Mangas.isUnread(manga) && !Mangas.isFullyRead(manga);
|
||||
}
|
||||
|
||||
static getPartiallyRead<Mangas extends MangaUnreadInfo>(mangas: Mangas[]): Mangas[] {
|
||||
return mangas.filter(Mangas.isPartiallyRead);
|
||||
}
|
||||
|
||||
static getThumbnailUrl(manga: Partial<MangaThumbnailInfo>): string {
|
||||
const thumbnailUrl = manga.thumbnailUrl
|
||||
? `${manga.thumbnailUrl}?fetchedAt=${manga.thumbnailUrlLastFetched}`
|
||||
: '';
|
||||
return requestManager.getValidImgUrlFor(thumbnailUrl);
|
||||
}
|
||||
|
||||
static getDuplicateLibraryMangas(
|
||||
title: string,
|
||||
): ReturnType<typeof requestManager.getMangas<GetMangasBaseQuery, GetMangasBaseQueryVariables>> {
|
||||
return requestManager.getMangas<GetMangasBaseQuery, GetMangasBaseQueryVariables>(GET_MANGAS_BASE, {
|
||||
condition: { inLibrary: true },
|
||||
filter: { title: { likeInsensitive: title } },
|
||||
});
|
||||
}
|
||||
|
||||
static async getChapterIdsWithState(
|
||||
mangaIds: number[],
|
||||
state: Pick<ChapterConditionInput, 'isRead' | 'isDownloaded' | 'isBookmarked'>,
|
||||
): Promise<GetMangasChapterIdsWithStateQuery['chapters']['nodes']> {
|
||||
const { data } = await requestManager.getMangasChapterIdsWithState(mangaIds, state).response;
|
||||
return data.chapters.nodes;
|
||||
}
|
||||
|
||||
static async downloadChapters(
|
||||
mangaIds: number[],
|
||||
{ size, onlyUnread, downloadAhead = false }: DownloadChaptersOptions = {},
|
||||
disableConfirmation?: boolean,
|
||||
): Promise<void> {
|
||||
const [chaptersToConsider, unReadDownloadedChapters] = await Promise.all([
|
||||
Mangas.getChapterIdsWithState(mangaIds, {
|
||||
isRead: onlyUnread ? false : undefined,
|
||||
isDownloaded: false,
|
||||
}),
|
||||
downloadAhead ? Mangas.getChapterIdsWithState(mangaIds, { isRead: false, isDownloaded: true }) : [],
|
||||
]);
|
||||
|
||||
type MangaIdToDownloadSize = [MangaId: string, DownloadSize: number | undefined];
|
||||
|
||||
const mangaIdToDefaultDownloadSize = mangaIds.map((mangaId) => [
|
||||
String(mangaId),
|
||||
size,
|
||||
]) satisfies MangaIdToDownloadSize[];
|
||||
|
||||
const mangaIdToChaptersToConsider = Object.groupBy(chaptersToConsider, ({ mangaId }) => mangaId);
|
||||
const mangaIdToUnReadDownloadedChapters = Object.groupBy(unReadDownloadedChapters, ({ mangaId }) => mangaId);
|
||||
|
||||
const mangaIdToDownloadSize = Object.entries(mangaIdToUnReadDownloadedChapters).map(
|
||||
([mangaId, downloadedChapters = []]) => {
|
||||
const downloadAheadSize = Math.max(0, (size ?? downloadedChapters.length) - downloadedChapters.length);
|
||||
const actualSize = downloadAhead ? downloadAheadSize : size;
|
||||
|
||||
return [mangaId, actualSize];
|
||||
},
|
||||
) satisfies MangaIdToDownloadSize[];
|
||||
|
||||
const mangaIdToActualDownloadSize = Object.entries(
|
||||
Object.fromEntries([...mangaIdToDefaultDownloadSize, ...mangaIdToDownloadSize]),
|
||||
) satisfies MangaIdToDownloadSize[];
|
||||
|
||||
const chapterIdsToDownload = mangaIdToActualDownloadSize
|
||||
.map(([mangaId, actualSize]) => {
|
||||
const mangaChapters = mangaIdToChaptersToConsider[Number(mangaId)] ?? [];
|
||||
|
||||
if (!mangaChapters.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const shouldDownloadAll = actualSize === undefined;
|
||||
if (shouldDownloadAll) {
|
||||
return mangaChapters;
|
||||
}
|
||||
|
||||
const uniqueMangaChapters = Chapters.removeDuplicates(mangaChapters[0], mangaChapters);
|
||||
const uniqueMangaChaptersToDownload = uniqueMangaChapters.slice(0, actualSize);
|
||||
|
||||
return Chapters.addDuplicates(uniqueMangaChaptersToDownload, mangaChapters);
|
||||
})
|
||||
.flat();
|
||||
|
||||
if (!chapterIdsToDownload.length) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return Chapters.download(Chapters.getIds(chapterIdsToDownload), disableConfirmation);
|
||||
}
|
||||
|
||||
static async deleteChapters(mangaIds: number[], disableConfirmation?: boolean): Promise<void> {
|
||||
const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isDownloaded: true });
|
||||
return Chapters.delete(Chapters.getIds(chapters), disableConfirmation);
|
||||
}
|
||||
|
||||
static async markAsRead(
|
||||
mangaIds: number[],
|
||||
wasManuallyMarkedAsRead: boolean = false,
|
||||
disableConfirmation?: boolean,
|
||||
): Promise<void> {
|
||||
const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isRead: false });
|
||||
return Chapters.markAsRead(
|
||||
chapters,
|
||||
wasManuallyMarkedAsRead,
|
||||
mangaIds.length === 1 ? mangaIds[0] : undefined,
|
||||
disableConfirmation,
|
||||
);
|
||||
}
|
||||
|
||||
static async markAsUnread(mangaIds: number[], disableConfirmation?: boolean): Promise<void> {
|
||||
const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isRead: true });
|
||||
return Chapters.markAsUnread(Chapters.getIds(chapters), disableConfirmation);
|
||||
}
|
||||
|
||||
static async removeFromLibrary(mangaIds: number[], disableConfirmation?: boolean): Promise<void> {
|
||||
const { removeMangaFromCategories } = await getMetadataServerSettings();
|
||||
return Mangas.executeAction(
|
||||
'remove_from_library',
|
||||
mangaIds.length,
|
||||
() =>
|
||||
requestManager.updateMangas(mangaIds, {
|
||||
updateMangas: { inLibrary: false },
|
||||
updateMangasCategories: removeMangaFromCategories ? { clearCategories: true } : undefined,
|
||||
}).response,
|
||||
disableConfirmation,
|
||||
);
|
||||
}
|
||||
|
||||
static async changeCategories(
|
||||
mangaIds: number[],
|
||||
patch: UpdateMangaCategoriesPatchInput,
|
||||
disableConfirmation?: boolean,
|
||||
): Promise<void> {
|
||||
return Mangas.executeAction(
|
||||
'change_categories',
|
||||
mangaIds.length,
|
||||
() => requestManager.updateMangasCategories(mangaIds, patch).response,
|
||||
disableConfirmation,
|
||||
);
|
||||
}
|
||||
|
||||
private static migrateChapters(
|
||||
mode: MigrateMode,
|
||||
mangaToMigrate: GetMangaToMigrateQuery['manga'],
|
||||
mangaToMigrateToInfo: GetMangaToMigrateToFetchMutation,
|
||||
deleteChapters: boolean,
|
||||
): MigrateAction {
|
||||
if (!mangaToMigrate.chapters || !mangaToMigrateToInfo.fetchChapters?.chapters) {
|
||||
throw new Error('Chapters are missing');
|
||||
}
|
||||
|
||||
const chaptersToMigrate = mangaToMigrate.chapters.nodes;
|
||||
|
||||
const chaptersToMigrateTo = mangaToMigrateToInfo.fetchChapters?.chapters;
|
||||
const migratableChapters = Chapters.getMatchingChapterNumberChapters(chaptersToMigrate, chaptersToMigrateTo);
|
||||
|
||||
const readChapters: number[] = [];
|
||||
const bookmarkedChapters: number[] = [];
|
||||
|
||||
migratableChapters.forEach(([chapterToMigrate, chapterToMigrateTo]) => {
|
||||
const { isRead, isBookmarked } = chapterToMigrate;
|
||||
|
||||
if (isRead) {
|
||||
readChapters.push(chapterToMigrateTo.id);
|
||||
}
|
||||
|
||||
if (isBookmarked) {
|
||||
bookmarkedChapters.push(chapterToMigrateTo.id);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
copy: () =>
|
||||
[
|
||||
readChapters.length && requestManager.updateChapters(readChapters, { isRead: true }).response,
|
||||
bookmarkedChapters.length &&
|
||||
requestManager.updateChapters(bookmarkedChapters, { isBookmarked: true }).response,
|
||||
].filter((promise) => !!promise),
|
||||
cleanup: () =>
|
||||
mode === 'migrate'
|
||||
? [
|
||||
deleteChapters
|
||||
? requestManager.deleteDownloadedChapters(
|
||||
Chapters.getIds(Chapters.getDownloaded(mangaToMigrate.chapters?.nodes ?? [])),
|
||||
).response
|
||||
: Promise.resolve(),
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
private static migrateTracking(
|
||||
mode: MigrateMode,
|
||||
mangaToMigrate: MangaToMigrate,
|
||||
mangaToMigrateTo: MangaToMigrateTo,
|
||||
): MigrateAction {
|
||||
if (!mangaToMigrate.trackRecords) {
|
||||
throw new Error('TrackRecords of manga to migrate are missing');
|
||||
}
|
||||
|
||||
if (!mangaToMigrateTo.trackRecords) {
|
||||
throw new Error('TrackRecords of manga to migrate to are missing');
|
||||
}
|
||||
|
||||
const trackBindingsToAdd = mangaToMigrate.trackRecords.nodes.filter((trackRecordToMigrate) =>
|
||||
mangaToMigrateTo.trackRecords?.nodes.every(
|
||||
(trackRecord) => trackRecordToMigrate.remoteId !== trackRecord.remoteId,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
copy: () =>
|
||||
trackBindingsToAdd.map(
|
||||
(trackRecord) =>
|
||||
requestManager.bindTracker(
|
||||
mangaToMigrateTo.id,
|
||||
trackRecord.trackerId,
|
||||
trackRecord.remoteId,
|
||||
trackRecord.private,
|
||||
).response,
|
||||
),
|
||||
cleanup: () =>
|
||||
mode === 'migrate'
|
||||
? (mangaToMigrate.trackRecords?.nodes.map(
|
||||
(trackRecord) => requestManager.unbindTracker(trackRecord.id).response,
|
||||
) ?? [])
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
private static migrateManga(
|
||||
mode: MigrateMode,
|
||||
mangaToMigrateFrom: MangaToMigrate,
|
||||
mangaToMigrateTo: MangaToMigrateTo,
|
||||
migrateCategories: boolean,
|
||||
removeMangaFromCategories: boolean,
|
||||
): MigrateAction {
|
||||
if (migrateCategories && !mangaToMigrateFrom?.categories) {
|
||||
throw new Error('Categories are missing');
|
||||
}
|
||||
|
||||
return {
|
||||
copy: () => [
|
||||
requestManager.updateManga(mangaToMigrateTo.id, {
|
||||
updateManga: { inLibrary: true },
|
||||
updateMangaCategories: migrateCategories
|
||||
? {
|
||||
addToCategories: mangaToMigrateFrom.categories?.nodes.map((category) => category.id),
|
||||
}
|
||||
: undefined,
|
||||
}).response,
|
||||
],
|
||||
cleanup: () =>
|
||||
mode === 'migrate'
|
||||
? [
|
||||
requestManager.updateManga(mangaToMigrateFrom.id, {
|
||||
updateManga: { inLibrary: false },
|
||||
updateMangaCategories: removeMangaFromCategories ? { clearCategories: true } : undefined,
|
||||
}).response,
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
static async migrate(
|
||||
mangaId: MangaIdInfo['id'],
|
||||
mangaIdToMigrateTo: number,
|
||||
{
|
||||
mode,
|
||||
migrateChapters,
|
||||
migrateCategories,
|
||||
migrateTracking,
|
||||
deleteChapters,
|
||||
}: Omit<MigrateOptions, 'mangaIdToMigrateTo'>,
|
||||
disableConfirmation?: boolean,
|
||||
): Promise<void> {
|
||||
return Mangas.executeAction(
|
||||
'migrate',
|
||||
1,
|
||||
async () => {
|
||||
const [{ data: mangaToMigrateData }, { data: mangaToMigrateToData }, { removeMangaFromCategories }] =
|
||||
await Promise.all([
|
||||
requestManager.getMangaToMigrate(mangaId, {
|
||||
migrateChapters,
|
||||
migrateCategories,
|
||||
migrateTracking,
|
||||
deleteChapters,
|
||||
}).response,
|
||||
requestManager.getMangaToMigrateToFetch(mangaIdToMigrateTo, {
|
||||
migrateChapters,
|
||||
migrateCategories,
|
||||
migrateTracking,
|
||||
apolloOptions: { errorPolicy: 'all' },
|
||||
}).response,
|
||||
getMetadataServerSettings(),
|
||||
]);
|
||||
|
||||
if (!mangaToMigrateData.manga || !mangaToMigrateToData?.fetchManga?.manga) {
|
||||
throw new Error('Mangas::migrate: missing manga data');
|
||||
}
|
||||
|
||||
if (migrateChapters && !mangaToMigrateData.manga.chapters) {
|
||||
throw new Error('Mangas::migrate: missing chapters data');
|
||||
}
|
||||
|
||||
if (!mangaToMigrateToData.fetchChapters?.chapters) {
|
||||
mangaToMigrateToData.fetchChapters = { chapters: [] };
|
||||
}
|
||||
|
||||
const performMigrationAction = async (
|
||||
migrateAction: keyof MigrateAction,
|
||||
...actions: MigrateAction[]
|
||||
) => Promise.all(actions.map((action) => action[migrateAction]()).flat());
|
||||
|
||||
const performMigrationActions = async (
|
||||
...actionCreators: [boolean | undefined, MigrateActionCreator][]
|
||||
) => {
|
||||
const migrationActions: TupleUnion<keyof MigrateAction> = ['copy', 'cleanup'];
|
||||
|
||||
const actions = actionCreators
|
||||
.filter(([performAction]) => performAction)
|
||||
.map(([, actionCreator]) => actionCreator());
|
||||
|
||||
for (const migrationAction of migrationActions) {
|
||||
// the migration actions (copy, cleanup) are supposed to be run sequentially to ensure that the cleanup
|
||||
// only happens in case the copy succeeded
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await performMigrationAction(migrationAction, ...actions);
|
||||
}
|
||||
};
|
||||
|
||||
await performMigrationActions(
|
||||
[
|
||||
migrateChapters,
|
||||
() =>
|
||||
Mangas.migrateChapters(
|
||||
mode,
|
||||
mangaToMigrateData.manga,
|
||||
mangaToMigrateToData,
|
||||
!!deleteChapters,
|
||||
),
|
||||
],
|
||||
[
|
||||
migrateTracking,
|
||||
() =>
|
||||
Mangas.migrateTracking(
|
||||
mode,
|
||||
mangaToMigrateData.manga,
|
||||
mangaToMigrateToData.fetchManga!.manga,
|
||||
),
|
||||
],
|
||||
[
|
||||
true,
|
||||
() =>
|
||||
Mangas.migrateManga(
|
||||
mode,
|
||||
mangaToMigrateData.manga,
|
||||
mangaToMigrateToData.fetchManga!.manga,
|
||||
!!migrateCategories,
|
||||
removeMangaFromCategories,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
disableConfirmation,
|
||||
);
|
||||
}
|
||||
|
||||
private static async executeAction(
|
||||
action: MangaAction,
|
||||
itemCount: number,
|
||||
fnToExecute: () => Promise<unknown>,
|
||||
disableConfirmation?: boolean,
|
||||
): Promise<void> {
|
||||
const { always, bulkAction, bulkActionCountForce } = MANGA_ACTION_TO_CONFIRMATION_REQUIRED[action];
|
||||
const requiresConfirmation =
|
||||
!disableConfirmation &&
|
||||
(always || (bulkAction && itemCount > 1) || (bulkActionCountForce && itemCount >= bulkActionCountForce));
|
||||
const confirmationMessage = MANGA_ACTION_TO_TRANSLATION[action].confirmation;
|
||||
|
||||
try {
|
||||
if (requiresConfirmation) {
|
||||
assertIsDefined(confirmationMessage);
|
||||
|
||||
try {
|
||||
await awaitConfirmation({
|
||||
title: translate('global.label.are_you_sure'),
|
||||
message: translate(confirmationMessage, { count: itemCount }),
|
||||
actions: {
|
||||
confirm: {
|
||||
title: translate('global.button.ok'),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await fnToExecute();
|
||||
makeToast(translate(MANGA_ACTION_TO_TRANSLATION[action].success, { count: itemCount }), 'success');
|
||||
} catch (e) {
|
||||
makeToast(
|
||||
translate(MANGA_ACTION_TO_TRANSLATION[action].error, { count: itemCount }),
|
||||
'error',
|
||||
getErrorMessage(e),
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static async performAction<Action extends MangaAction>(
|
||||
action: Action,
|
||||
mangaIds: number[],
|
||||
{
|
||||
wasManuallyMarkedAsRead,
|
||||
changeCategoriesPatch,
|
||||
mangaIdToMigrateTo,
|
||||
downloadAhead,
|
||||
onlyUnread,
|
||||
size,
|
||||
...migrateOptions
|
||||
}: PerformActionOptions<Action>,
|
||||
disableConfirmation?: boolean,
|
||||
): Promise<void> {
|
||||
switch (action) {
|
||||
case 'download':
|
||||
return Mangas.downloadChapters(mangaIds, { downloadAhead, onlyUnread, size }, disableConfirmation);
|
||||
case 'delete':
|
||||
return Mangas.deleteChapters(mangaIds, disableConfirmation);
|
||||
case 'mark_as_read':
|
||||
return Mangas.markAsRead(mangaIds, wasManuallyMarkedAsRead!, disableConfirmation);
|
||||
case 'mark_as_unread':
|
||||
return Mangas.markAsUnread(mangaIds, disableConfirmation);
|
||||
case 'remove_from_library':
|
||||
return Mangas.removeFromLibrary(mangaIds, disableConfirmation);
|
||||
case 'change_categories':
|
||||
return Mangas.changeCategories(mangaIds, changeCategoriesPatch!, disableConfirmation);
|
||||
case 'migrate': {
|
||||
return Mangas.migrate(
|
||||
mangaIds[0],
|
||||
mangaIdToMigrateTo!,
|
||||
migrateOptions as unknown as MigrateOptions,
|
||||
disableConfirmation,
|
||||
);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Mangas::performAction: unknown action "${action}"`);
|
||||
}
|
||||
}
|
||||
|
||||
static getType(manga: MangaGenreInfo & MangaSourceNameInfo & MangaSourceLngInfo): MangaType {
|
||||
if (Mangas.isType(manga, MangaType.MANGA)) {
|
||||
return MangaType.MANGA;
|
||||
}
|
||||
|
||||
if (Mangas.isType(manga, MangaType.COMIC)) {
|
||||
return MangaType.COMIC;
|
||||
}
|
||||
|
||||
if (Mangas.isType(manga, MangaType.WEBTOON)) {
|
||||
return MangaType.WEBTOON;
|
||||
}
|
||||
|
||||
if (Mangas.isType(manga, MangaType.MANHWA)) {
|
||||
return MangaType.MANHWA;
|
||||
}
|
||||
|
||||
if (Mangas.isType(manga, MangaType.MANHUA)) {
|
||||
return MangaType.MANHUA;
|
||||
}
|
||||
|
||||
return MangaType.MANGA;
|
||||
}
|
||||
|
||||
static isType(manga: MangaGenreInfo & MangaSourceNameInfo & MangaSourceLngInfo, type: MangaType): boolean {
|
||||
const translateMangaTagsByMangaTypeEntries = Object.entries(MANGA_TAGS_BY_MANGA_TYPE).map(
|
||||
([mangaType, tags]) => [
|
||||
mangaType,
|
||||
['en', i18next.language, manga.source?.lang]
|
||||
.filter((lng) => !!lng)
|
||||
.flatMap((language) => tags.flatMap((tag) => translate(tag, { lng: language }))),
|
||||
],
|
||||
);
|
||||
const translatedMangaTagsByMangaType = Object.fromEntries(translateMangaTagsByMangaTypeEntries) as Record<
|
||||
string,
|
||||
string[]
|
||||
>;
|
||||
|
||||
const isMatchByGenre = manga.genre.some((genre) =>
|
||||
translatedMangaTagsByMangaType[type].some((tag) => genre.toLowerCase().includes(tag.toLowerCase())),
|
||||
);
|
||||
const isMatchBySource = SOURCES_BY_MANGA_TYPE[type].includes(manga.source?.name.toLowerCase() ?? '');
|
||||
|
||||
return isMatchByGenre || isMatchBySource;
|
||||
}
|
||||
|
||||
static isLongStripType(manga: MangaGenreInfo & MangaSourceNameInfo & MangaSourceLngInfo): boolean {
|
||||
return (
|
||||
Mangas.isType(manga, MangaType.WEBTOON) ||
|
||||
Mangas.isType(manga, MangaType.MANHWA) ||
|
||||
Mangas.isType(manga, MangaType.MANHUA)
|
||||
);
|
||||
}
|
||||
|
||||
static getArtists<Manga extends MangaArtistInfo>(manga: Manga): string[] | undefined {
|
||||
return manga.artist?.split(ARTIST_AUTHOR_SEPARATOR_REGEX);
|
||||
}
|
||||
|
||||
static getAuthors<Manga extends MangaAuthorInfo>(manga: Manga): string[] | undefined {
|
||||
return manga.author?.split(ARTIST_AUTHOR_SEPARATOR_REGEX);
|
||||
}
|
||||
|
||||
static createLocationState<Manga extends MangaTitleInfo>(
|
||||
manga: Manga,
|
||||
mode: MangaCardMode | undefined,
|
||||
): MangaLocationState {
|
||||
return {
|
||||
mangaTitle: manga.title,
|
||||
mode,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user