diff --git a/src/features/browse/Browse.types.ts b/src/features/browse/Browse.types.ts index abbc03769..b8dae1a88 100644 --- a/src/features/browse/Browse.types.ts +++ b/src/features/browse/Browse.types.ts @@ -14,6 +14,7 @@ export type MetadataBrowseSettings = { showNsfw: boolean; lastUsedSourceId: SourceIdInfo['id'] | null; shouldShowOnlySourcesWithResults: boolean; + showRelatedForEachManga: boolean; }; export enum BrowseTab { diff --git a/src/features/browse/screens/BrowseSettings.tsx b/src/features/browse/screens/BrowseSettings.tsx index 09f140bc4..a3b915589 100644 --- a/src/features/browse/screens/BrowseSettings.tsx +++ b/src/features/browse/screens/BrowseSettings.tsx @@ -53,7 +53,7 @@ export const BrowseSettings = () => { }; const { - settings: { hideLibraryEntries, showNsfw }, + settings: { hideLibraryEntries, showNsfw, showRelatedForEachManga }, } = useMetadataServerSettings(); const updateMetadataServerSettings = createUpdateMetadataServerSettings((e) => makeToast(t`Failed to save changes`, 'error', getErrorMessage(e)), @@ -146,6 +146,27 @@ export const BrowseSettings = () => { handleChange={(path) => updateSetting('localSourcePath', path)} /> + + {t`Manga`} + + } + > + + + + updateMetadataServerSettings('showRelatedForEachManga', !showRelatedForEachManga) + } + /> + + diff --git a/src/features/manga/components/RelatedMangaButton.tsx b/src/features/manga/components/RelatedMangaButton.tsx new file mode 100644 index 000000000..765addbf7 --- /dev/null +++ b/src/features/manga/components/RelatedMangaButton.tsx @@ -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 RecommendIcon from '@mui/icons-material/Recommend'; +import PopupState, { bindDialog, bindTrigger } from 'material-ui-popup-state'; +import Dialog from '@mui/material/Dialog'; +import { useLingui } from '@lingui/react/macro'; +import { FlexWrapButton } from '@/base/components/buttons/FlexWrapButton.tsx'; +import { MangaRelated } from '@/features/tracker/components/MangaRelated.tsx'; +import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts'; +import type { MangaIdInfo } from '@/features/manga/Manga.types.ts'; +import { MediaQuery } from '@/base/utils/MediaQuery.tsx'; + +export const RelatedMangaButton = ({ manga }: { manga: MangaIdInfo }) => { + const { t } = useLingui(); + const isMobileWidth = MediaQuery.useIsMobileWidth(); + + const { + settings: { showRelatedForEachManga }, + } = useMetadataServerSettings(); + + if (!showRelatedForEachManga) { + return null; + } + + return ( + + {(popupState) => ( + <> + + + {t`Related`} + + {popupState.isOpen && ( + + + + )} + + )} + + ); +}; diff --git a/src/features/manga/components/details/MangaDetails.tsx b/src/features/manga/components/details/MangaDetails.tsx index 7f0b53718..5066487e4 100644 --- a/src/features/manga/components/details/MangaDetails.tsx +++ b/src/features/manga/components/details/MangaDetails.tsx @@ -25,6 +25,7 @@ import { Mangas } from '@/features/manga/services/Mangas.ts'; import { SpinnerImage } from '@/base/components/SpinnerImage.tsx'; import { FlexWrapButton } from '@/base/components/buttons/FlexWrapButton.tsx'; import { TrackMangaButton } from '@/features/manga/components/TrackMangaButton.tsx'; +import { RelatedMangaButton } from '@/features/manga/components/RelatedMangaButton.tsx'; import { useManageMangaLibraryState } from '@/features/manga/hooks/useManageMangaLibraryState.tsx'; import { Metadata as BaseMetadata } from '@/base/components/texts/Metadata.tsx'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; @@ -293,6 +294,7 @@ export const MangaDetails = ({ {manga.inLibrary ? t`In Library` : t`Add To Library`} + diff --git a/src/features/metadata/Metadata.constants.ts b/src/features/metadata/Metadata.constants.ts index da4dbc47e..61124b6e5 100644 --- a/src/features/metadata/Metadata.constants.ts +++ b/src/features/metadata/Metadata.constants.ts @@ -388,6 +388,9 @@ export const APP_METADATA: Record< shouldShowOnlySourcesWithResults: { convert: convertToBoolean, }, + showRelatedForEachManga: { + convert: convertToBoolean, + }, excludedScanlators: { convert: convertToObject, }, @@ -460,6 +463,7 @@ export const GLOBAL_METADATA_KEYS: AppMetadataKeys[] = [ 'showNsfw', 'lastUsedSourceId', 'shouldShowOnlySourcesWithResults', + 'showRelatedForEachManga', // history 'hideHistory', diff --git a/src/features/settings/Settings.constants.ts b/src/features/settings/Settings.constants.ts index 860573234..a901a8436 100644 --- a/src/features/settings/Settings.constants.ts +++ b/src/features/settings/Settings.constants.ts @@ -72,6 +72,7 @@ export const SERVER_SETTINGS_METADATA_DEFAULT: MetadataServerSettings = { showNsfw: true, lastUsedSourceId: null, shouldShowOnlySourcesWithResults: true, + showRelatedForEachManga: true, // history hideHistory: false, diff --git a/src/features/tracker/components/MangaRelated.tsx b/src/features/tracker/components/MangaRelated.tsx new file mode 100644 index 000000000..cb0e665e9 --- /dev/null +++ b/src/features/tracker/components/MangaRelated.tsx @@ -0,0 +1,219 @@ +/* + * 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 Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Link from '@mui/material/Link'; +import Typography from '@mui/material/Typography'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import { useLingui } from '@lingui/react/macro'; +import type { MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; +import { i18n } from '@/i18n'; +import { requestManager } from '@/lib/requests/RequestManager.ts'; +import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx'; +import { EmptyView } from '@/base/components/feedback/EmptyView.tsx'; +import { AvatarSpinner } from '@/base/components/AvatarSpinner.tsx'; +import { SpinnerImage } from '@/base/components/SpinnerImage.tsx'; +import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; +import { getErrorMessage } from '@/lib/HelperFunctions.ts'; +import type { + GetMangaRelatedQuery, + GetTrackersSettingsQuery, + RelatedMangaFieldsFragment, +} from '@/lib/graphql/generated/graphql.ts'; +import { GET_TRACKERS_SETTINGS } from '@/lib/graphql/tracker/TrackerQuery.ts'; +import type { MangaIdInfo } from '@/features/manga/Manga.types.ts'; +import { STABLE_EMPTY_ARRAY } from '@/base/Base.constants.ts'; + +type RelatedManga = GetMangaRelatedQuery['mangaRelated']; + +type SectionDefinition = { + title: MessageDescriptor; + trackerName: string; + items: (related: RelatedManga) => RelatedMangaFieldsFragment[]; +}; + +// The tracker names must match the names provided by the server (see TrackerManager on the server). +const ANILIST_NAME = 'AniList'; +const MYANIMELIST_NAME = 'MyAnimeList'; + +const SECTIONS: SectionDefinition[] = [ + { + title: msg`AniList - Relations`, + trackerName: ANILIST_NAME, + items: (related) => related.anilistRelations, + }, + { + title: msg`AniList - Recommendations`, + trackerName: ANILIST_NAME, + items: (related) => related.anilistRecommendations, + }, + { + title: msg`MyAnimeList - Related Entries`, + trackerName: MYANIMELIST_NAME, + items: (related) => related.myanimelistRelations, + }, + { + title: msg`MyAnimeList - Recommendations`, + trackerName: MYANIMELIST_NAME, + items: (related) => related.myanimelistRecommendations, + }, +]; + +const RelatedMangaCard = ({ item }: { item: RelatedMangaFieldsFragment }) => ( + + + + + {item.relationType && ( + + {item.relationType} + + )} + + {item.title} + + +); + +const RelatedSection = ({ + title, + iconUrl, + items, +}: { + title: string; + iconUrl?: string; + items: RelatedMangaFieldsFragment[]; +}) => { + const { t } = useLingui(); + + return ( + + + {iconUrl && ( + + )} + {title} + + {items.length ? ( + + {items.map((item) => ( + + ))} + + ) : ( + + {t`Nothing found`} + + )} + + ); +}; + +export const MangaRelated = ({ manga }: { manga: MangaIdInfo }) => { + const { t } = useLingui(); + + const relatedList = requestManager.useGetMangaRelated(manga.id); + const trackerList = requestManager.useGetTrackerList(GET_TRACKERS_SETTINGS); + + const trackers = trackerList.data?.trackers.nodes ?? STABLE_EMPTY_ARRAY; + const getIconUrl = (trackerName: string): string | undefined => { + const tracker = trackers.find(({ name }) => name === trackerName); + return tracker ? requestManager.getValidImgUrlFor(tracker.icon) : undefined; + }; + + const loading = relatedList.loading || trackerList.loading; + const error = relatedList.error ?? trackerList.error; + + if (error) { + return ( + <> + {t`Related`} + + { + relatedList.refetch().catch(defaultPromiseErrorHandler('MangaRelated::refetch: related')); + trackerList.refetch().catch(defaultPromiseErrorHandler('MangaRelated::refetch: trackers')); + }} + /> + + + ); + } + + if (loading || !relatedList.data) { + return ( + <> + {t`Related`} + + + + + ); + } + + const related = relatedList.data.mangaRelated; + + return ( + <> + {t`Related`} + + + {SECTIONS.map((section) => ( + + ))} + + + + ); +}; diff --git a/src/i18n/locales/en.po b/src/i18n/locales/en.po index 625c91f57..3c206fc06 100644 --- a/src/i18n/locales/en.po +++ b/src/i18n/locales/en.po @@ -437,6 +437,14 @@ msgstr "Alpha" msgid "Also remove from {0}" msgstr "Also remove from {0}" +#: src/features/tracker/components/MangaRelated.tsx +msgid "AniList - Recommendations" +msgstr "AniList - Recommendations" + +#: src/features/tracker/components/MangaRelated.tsx +msgid "AniList - Relations" +msgstr "AniList - Relations" + #: src/features/settings/screens/Appearance.tsx #: src/features/settings/screens/Settings.tsx msgid "Appearance" @@ -1195,6 +1203,10 @@ msgstr "Current:" msgid "Currently publishing" msgstr "Currently publishing" +#: src/features/browse/screens/BrowseSettings.tsx +msgid "Currently supported for: AniList, MyAnimeList" +msgstr "Currently supported for: AniList, MyAnimeList" + #: src/features/settings/Settings.constants.ts msgid "Custom" msgstr "Custom" @@ -2274,6 +2286,7 @@ msgstr "" "UI specific settings that are stored on the server are per device.\n" "This makes it possible to have e.g. different settings on a desktop and a smartphone" +#: src/features/browse/screens/BrowseSettings.tsx #: src/features/manga/Manga.constants.ts #: src/features/manga/screens/Manga.tsx #: src/features/reader/settings/behaviour/components/ReaderSettingExitMode.tsx @@ -2467,6 +2480,14 @@ msgstr "Multi" msgid "Multiply" msgstr "Multiply" +#: src/features/tracker/components/MangaRelated.tsx +msgid "MyAnimeList - Recommendations" +msgstr "MyAnimeList - Recommendations" + +#: src/features/tracker/components/MangaRelated.tsx +msgid "MyAnimeList - Related Entries" +msgstr "MyAnimeList - Related Entries" + #: src/features/settings/components/images/KeyValueItem.tsx #: src/features/theme/components/CreateThemeDialog.tsx msgid "Name" @@ -2560,6 +2581,10 @@ msgstr "Not yet published" msgid "Not yet released" msgstr "Not yet released" +#: src/features/tracker/components/MangaRelated.tsx +msgid "Nothing found" +msgstr "Nothing found" + #: src/features/tracker/Tracker.constants.ts msgid "Novel" msgstr "Novel" @@ -2867,6 +2892,11 @@ msgstr "Red" msgid "Refresh" msgstr "Refresh" +#: src/features/manga/components/RelatedMangaButton.tsx +#: src/features/tracker/components/MangaRelated.tsx +msgid "Related" +msgstr "Related" + #: src/features/tracker/Tracker.constants.ts msgid "Releasing" msgstr "Releasing" @@ -3278,6 +3308,10 @@ msgstr "Show page number" msgid "Show reading mode" msgstr "Show reading mode" +#: src/features/browse/screens/BrowseSettings.tsx +msgid "Show related for each manga" +msgstr "Show related for each manga" + #: src/features/reader/settings/behaviour/ReaderBehaviourSettings.tsx msgid "Show tap zones overlay" msgstr "Show tap zones overlay" @@ -3754,6 +3788,7 @@ msgstr "UI Login" #: src/features/source/configuration/screens/SourceConfigure.tsx #: src/features/theme/components/CreateThemeDialog.tsx #: src/features/theme/components/ThemeList.tsx +#: src/features/tracker/components/MangaRelated.tsx #: src/features/tracker/components/TrackerSearch.tsx #: src/features/tracker/components/TrackManga.tsx #: src/features/tracker/screens/TrackingSettings.tsx diff --git a/src/lib/graphql/generated/graphql.ts b/src/lib/graphql/generated/graphql.ts index ec473712a..8c765ae0f 100644 --- a/src/lib/graphql/generated/graphql.ts +++ b/src/lib/graphql/generated/graphql.ts @@ -4052,6 +4052,58 @@ export type TrackerSearchQuery = { }; }; +export type RelatedMangaFieldsFragment = { + __typename: 'TrackRelatedMangaType'; + remoteId: string; + title: string; + coverUrl: string; + trackingUrl: string; + relationType: string | null; +}; + +export type GetMangaRelatedQueryVariables = Exact<{ + mangaId: number; +}>; + +export type GetMangaRelatedQuery = { + __typename: 'Query'; + mangaRelated: { + __typename: 'MangaRelatedPayload'; + anilistRelations: Array<{ + __typename: 'TrackRelatedMangaType'; + remoteId: string; + title: string; + coverUrl: string; + trackingUrl: string; + relationType: string | null; + }>; + anilistRecommendations: Array<{ + __typename: 'TrackRelatedMangaType'; + remoteId: string; + title: string; + coverUrl: string; + trackingUrl: string; + relationType: string | null; + }>; + myanimelistRelations: Array<{ + __typename: 'TrackRelatedMangaType'; + remoteId: string; + title: string; + coverUrl: string; + trackingUrl: string; + relationType: string | null; + }>; + myanimelistRecommendations: Array<{ + __typename: 'TrackRelatedMangaType'; + remoteId: string; + title: string; + coverUrl: string; + trackingUrl: string; + relationType: string | null; + }>; + }; +}; + export type UpdaterMangaFieldsFragment = { __typename: 'MangaUpdateType'; status: Types.MangaJobStatus; diff --git a/src/lib/graphql/tracker/TrackerQuery.ts b/src/lib/graphql/tracker/TrackerQuery.ts index 661f496c2..b042a0536 100644 --- a/src/lib/graphql/tracker/TrackerQuery.ts +++ b/src/lib/graphql/tracker/TrackerQuery.ts @@ -56,3 +56,34 @@ export const TRACKER_SEARCH = gql` } } `; + +export const RELATED_MANGA_FIELDS = gql` + fragment RELATED_MANGA_FIELDS on TrackRelatedMangaType { + remoteId + title + coverUrl + trackingUrl + relationType + } +`; + +export const GET_MANGA_RELATED = gql` + ${RELATED_MANGA_FIELDS} + + query GET_MANGA_RELATED($mangaId: Int!) { + mangaRelated(input: { mangaId: $mangaId }) { + anilistRelations { + ...RELATED_MANGA_FIELDS + } + anilistRecommendations { + ...RELATED_MANGA_FIELDS + } + myanimelistRelations { + ...RELATED_MANGA_FIELDS + } + myanimelistRecommendations { + ...RELATED_MANGA_FIELDS + } + } + } +`; diff --git a/src/lib/requests/RequestManager.ts b/src/lib/requests/RequestManager.ts index 3f42faf39..d236fd056 100644 --- a/src/lib/requests/RequestManager.ts +++ b/src/lib/requests/RequestManager.ts @@ -84,6 +84,8 @@ import type { GetMangaChaptersFetchMutationVariables, GetMangaFetchMutation, GetMangaFetchMutationVariables, + GetMangaRelatedQuery, + GetMangaRelatedQueryVariables, GetMangasLibraryQuery, GetMangasLibraryQueryVariables, GetMangaToMigrateQuery, @@ -307,7 +309,7 @@ import { GET_DOWNLOAD_STATUS } from '@/lib/graphql/download/DownloaderQuery.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import type { QueuePriority } from '@/lib/Queue.ts'; import { SourceAwareQueue } from '@/lib/SourceAwareQueue.ts'; -import { TRACKER_SEARCH } from '@/lib/graphql/tracker/TrackerQuery.ts'; +import { GET_MANGA_RELATED, TRACKER_SEARCH } from '@/lib/graphql/tracker/TrackerQuery.ts'; import { TRACKER_BIND, TRACKER_FETCH_BIND, @@ -3698,10 +3700,30 @@ export class RequestManager { return this.doRequest(GQLMethod.USE_QUERY, TRACKER_SEARCH, { trackerId, query }, options); } + public useGetMangaRelated( + mangaId: number, + options?: QueryHookOptions, + ): AbortableApolloUseQueryResponse { + return this.doRequest(GQLMethod.USE_QUERY, GET_MANGA_RELATED, { mangaId }, options); + } + + /** + * Evicts the cached "mangaRelated" results so they are refetched the next time the Related + * modal is opened. The related/recommendation data depends on the manga's tracker bindings + * (which remote id is used), so it has to be invalidated whenever a binding changes. + */ + private evictMangaRelated(): void { + this.graphQLClient.client.cache.evict({ fieldName: 'mangaRelated' }); + this.graphQLClient.client.cache.gc(); + } + public useBindTracker( options?: MutationHookOptions, ): AbortableApolloUseMutationResponse { - return this.doRequest(GQLMethod.USE_MUTATION, TRACKER_BIND, undefined, options); + return this.doRequest(GQLMethod.USE_MUTATION, TRACKER_BIND, undefined, { + update: () => this.evictMangaRelated(), + ...options, + }); } public bindTracker( @@ -3711,11 +3733,14 @@ export class RequestManager { asPrivate: boolean, options?: MutationOptions, ): AbortableApolloMutationResponse { - return this.doRequest( + return this.doRequest( GQLMethod.MUTATION, TRACKER_BIND, { input: { mangaId, remoteId, trackerId, private: asPrivate } }, - options, + { + update: () => this.evictMangaRelated(), + ...options, + } as MutationOptions, ); } @@ -3728,10 +3753,11 @@ export class RequestManager { GQLMethod.MUTATION, TRACKER_UNBIND, { input: { recordId, deleteRemoteTrack } }, - { refetchQueries: [GET_MANGA_TRACK_RECORDS], ...options } as MutationOptions< - TrackerUnbindMutation, - TrackerUnbindMutationVariables - >, + { + refetchQueries: [GET_MANGA_TRACK_RECORDS], + update: () => this.evictMangaRelated(), + ...options, + } as MutationOptions, ); }