From 822e47084eb09f1ab01c05ce0a2f967e4cce35c5 Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Sat, 17 May 2025 22:17:15 +0200 Subject: [PATCH] Add functionality to pin sources --- public/locales/en.json | 5 ++++ src/lib/graphql/fragments/SourceFragments.ts | 5 ++++ src/lib/graphql/generated/graphql.ts | 16 +++++----- src/modules/core/utils/Languages.ts | 2 ++ src/modules/extension/Extensions.constants.ts | 1 + src/modules/extension/Extensions.utils.ts | 2 +- src/modules/metadata/Metadata.constants.ts | 2 ++ src/modules/source/Source.types.ts | 3 ++ src/modules/source/components/SourceCard.tsx | 25 ++++++++++++++++ src/modules/source/services/SourceMetadata.ts | 1 + src/modules/source/services/Sources.ts | 29 +++++++++++++++---- 11 files changed, 77 insertions(+), 14 deletions(-) diff --git a/public/locales/en.json b/public/locales/en.json index bcd1c965..d6a84485 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -445,6 +445,7 @@ "open_in_browser": "Open in browser", "other": "Other", "password": "Password", + "pinned": "Pinned", "placeholder": "Placeholder", "previous": "Previous", "right": "Right", @@ -1485,6 +1486,10 @@ }, "title": "Local source" }, + "pin": { + "add": "Pin source", + "remove": "Unpin source" + }, "title_one": "Source", "title_other": "Sources" }, diff --git a/src/lib/graphql/fragments/SourceFragments.ts b/src/lib/graphql/fragments/SourceFragments.ts index 61af7436..7a6942b0 100644 --- a/src/lib/graphql/fragments/SourceFragments.ts +++ b/src/lib/graphql/fragments/SourceFragments.ts @@ -38,6 +38,7 @@ export const SOURCE_MIGRATABLE_FIELDS = gql` export const SOURCE_LIST_FIELDS = gql` ${SOURCE_BASE_FIELDS} + ${SOURCE_META_FIELDS} fragment SOURCE_LIST_FIELDS on SourceType { ...SOURCE_BASE_FIELDS @@ -48,6 +49,10 @@ export const SOURCE_LIST_FIELDS = gql` isConfigurable supportsLatest + meta { + ...SOURCE_META_FIELDS + } + extension { pkgName repo diff --git a/src/lib/graphql/generated/graphql.ts b/src/lib/graphql/generated/graphql.ts index 750a72b6..1e16a3b8 100644 --- a/src/lib/graphql/generated/graphql.ts +++ b/src/lib/graphql/generated/graphql.ts @@ -2887,7 +2887,7 @@ export type SourceBaseFieldsFragment = { __typename?: 'SourceType', id: string, export type SourceMigratableFieldsFragment = { __typename?: 'SourceType', lang: string, iconUrl: string, id: string, name: string, displayName: string }; -export type SourceListFieldsFragment = { __typename?: 'SourceType', lang: string, iconUrl: string, isNsfw: boolean, isConfigurable: boolean, supportsLatest: boolean, id: string, name: string, displayName: string, extension: { __typename?: 'ExtensionType', pkgName: string, repo?: string | null } }; +export type SourceListFieldsFragment = { __typename?: 'SourceType', lang: string, iconUrl: string, isNsfw: boolean, isConfigurable: boolean, supportsLatest: boolean, id: string, name: string, displayName: string, meta: Array<{ __typename?: 'SourceMetaType', sourceId: string, key: string, value: string }>, extension: { __typename?: 'ExtensionType', pkgName: string, repo?: string | null } }; export type SourceBrowseFieldsFragment = { __typename?: 'SourceType', isConfigurable: boolean, supportsLatest: boolean, id: string, name: string, displayName: string, lang: string, meta: Array<{ __typename?: 'SourceMetaType', sourceId: string, key: string, value: string }>, filters: Array<{ __typename?: 'CheckBoxFilter', name: string, type: 'CheckBoxFilter', CheckBoxFilterDefault: boolean } | { __typename?: 'GroupFilter', name: string, type: 'GroupFilter', filters: Array<{ __typename?: 'CheckBoxFilter', name: string, type: 'CheckBoxFilter', CheckBoxFilterDefault: boolean } | { __typename?: 'GroupFilter' } | { __typename?: 'HeaderFilter', name: string, type: 'HeaderFilter' } | { __typename?: 'SelectFilter', name: string, values: Array, type: 'SelectFilter', SelectFilterDefault: number } | { __typename?: 'SeparatorFilter', name: string, type: 'SeparatorFilter' } | { __typename?: 'SortFilter', name: string, values: Array, type: 'SortFilter', SortFilterDefault?: { __typename?: 'SortSelection', ascending: boolean, index: number } | null } | { __typename?: 'TextFilter', name: string, type: 'TextFilter', TextFilterDefault: string } | { __typename?: 'TriStateFilter', name: string, type: 'TriStateFilter', TriStateFilterDefault: TriState }> } | { __typename?: 'HeaderFilter', name: string, type: 'HeaderFilter' } | { __typename?: 'SelectFilter', name: string, values: Array, type: 'SelectFilter', SelectFilterDefault: number } | { __typename?: 'SeparatorFilter', name: string, type: 'SeparatorFilter' } | { __typename?: 'SortFilter', name: string, values: Array, type: 'SortFilter', SortFilterDefault?: { __typename?: 'SortSelection', ascending: boolean, index: number } | null } | { __typename?: 'TextFilter', name: string, type: 'TextFilter', TextFilterDefault: string } | { __typename?: 'TriStateFilter', name: string, type: 'TriStateFilter', TriStateFilterDefault: TriState }> }; @@ -2903,15 +2903,15 @@ export type TrackRecordSearchFieldsFragment = { __typename?: 'TrackSearchType', export type TrackRecordBindFieldsFragment = { __typename?: 'TrackRecordType', id: number, remoteId: string, trackerId: number, remoteUrl: string, title: string, status: number, lastChapterRead: number, totalChapters: number, score: number, displayScore: string, startDate: string, finishDate: string }; -export type UpdaterMangaFieldsFragment = { __typename?: 'MangaUpdateType', status: MangaJobStatus, manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null } }; +export type UpdaterMangaFieldsFragment = { __typename?: 'MangaUpdateType', status: MangaJobStatus, manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null, unreadCount: number, downloadCount: number, bookmarkCount: number, hasDuplicateChapters: boolean, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, firstUnreadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, isRead: boolean, mangaId: number } | null, lastReadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, lastReadAt: string } | null, latestReadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, lastReadAt: string } | null, latestFetchedChapter?: { __typename?: 'ChapterType', id: number, fetchedAt: string } | null, latestUploadedChapter?: { __typename?: 'ChapterType', id: number, uploadDate: string } | null } }; export type UpdaterCategoryFieldsFragment = { __typename?: 'CategoryUpdateType', status: CategoryJobStatus, category: { __typename?: 'CategoryType', id: number, name: string } }; export type UpdaterJobInfoFieldsFragment = { __typename?: 'UpdaterJobsInfoType', isRunning: boolean, totalJobs: number, finishedJobs: number, skippedCategoriesCount: number, skippedMangasCount: number }; -export type UpdaterStatusFieldsFragment = { __typename?: 'LibraryUpdateStatus', jobsInfo: { __typename?: 'UpdaterJobsInfoType', isRunning: boolean, totalJobs: number, finishedJobs: number, skippedCategoriesCount: number, skippedMangasCount: number }, categoryUpdates: Array<{ __typename?: 'CategoryUpdateType', status: CategoryJobStatus, category: { __typename?: 'CategoryType', id: number, name: string } }>, mangaUpdates: Array<{ __typename?: 'MangaUpdateType', status: MangaJobStatus, manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null } }> }; +export type UpdaterStatusFieldsFragment = { __typename?: 'LibraryUpdateStatus', jobsInfo: { __typename?: 'UpdaterJobsInfoType', isRunning: boolean, totalJobs: number, finishedJobs: number, skippedCategoriesCount: number, skippedMangasCount: number }, categoryUpdates: Array<{ __typename?: 'CategoryUpdateType', status: CategoryJobStatus, category: { __typename?: 'CategoryType', id: number, name: string } }>, mangaUpdates: Array<{ __typename?: 'MangaUpdateType', status: MangaJobStatus, manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null, unreadCount: number, downloadCount: number, bookmarkCount: number, hasDuplicateChapters: boolean, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, firstUnreadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, isRead: boolean, mangaId: number } | null, lastReadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, lastReadAt: string } | null, latestReadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, lastReadAt: string } | null, latestFetchedChapter?: { __typename?: 'ChapterType', id: number, fetchedAt: string } | null, latestUploadedChapter?: { __typename?: 'ChapterType', id: number, uploadDate: string } | null } }> }; -export type UpdaterSubscriptionFieldsFragment = { __typename?: 'UpdaterUpdates', omittedUpdates: boolean, jobsInfo: { __typename?: 'UpdaterJobsInfoType', isRunning: boolean, totalJobs: number, finishedJobs: number, skippedCategoriesCount: number, skippedMangasCount: number }, categoryUpdates: Array<{ __typename?: 'CategoryUpdateType', status: CategoryJobStatus, category: { __typename?: 'CategoryType', id: number, name: string } }>, mangaUpdates: Array<{ __typename?: 'MangaUpdateType', status: MangaJobStatus, manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null } }> }; +export type UpdaterSubscriptionFieldsFragment = { __typename?: 'UpdaterUpdates', omittedUpdates: boolean, jobsInfo: { __typename?: 'UpdaterJobsInfoType', isRunning: boolean, totalJobs: number, finishedJobs: number, skippedCategoriesCount: number, skippedMangasCount: number }, categoryUpdates: Array<{ __typename?: 'CategoryUpdateType', status: CategoryJobStatus, category: { __typename?: 'CategoryType', id: number, name: string } }>, mangaUpdates: Array<{ __typename?: 'MangaUpdateType', status: MangaJobStatus, manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null, unreadCount: number, downloadCount: number, bookmarkCount: number, hasDuplicateChapters: boolean, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, firstUnreadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, isRead: boolean, mangaId: number } | null, lastReadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, lastReadAt: string } | null, latestReadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, lastReadAt: string } | null, latestFetchedChapter?: { __typename?: 'ChapterType', id: number, fetchedAt: string } | null, latestUploadedChapter?: { __typename?: 'ChapterType', id: number, uploadDate: string } | null } }> }; export type UpdaterStartStopFieldsFragment = { __typename?: 'UpdateStatus', isRunning: boolean }; @@ -3334,7 +3334,7 @@ export type UpdateLibraryMutationVariables = Exact<{ }>; -export type UpdateLibraryMutation = { __typename?: 'Mutation', updateLibrary?: { __typename?: 'UpdateLibraryPayload', updateStatus: { __typename?: 'LibraryUpdateStatus', jobsInfo: { __typename?: 'UpdaterJobsInfoType', isRunning: boolean, totalJobs: number, finishedJobs: number, skippedCategoriesCount: number, skippedMangasCount: number }, categoryUpdates: Array<{ __typename?: 'CategoryUpdateType', status: CategoryJobStatus, category: { __typename?: 'CategoryType', id: number, name: string } }>, mangaUpdates: Array<{ __typename?: 'MangaUpdateType', status: MangaJobStatus, manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null } }> } } | null }; +export type UpdateLibraryMutation = { __typename?: 'Mutation', updateLibrary?: { __typename?: 'UpdateLibraryPayload', updateStatus: { __typename?: 'LibraryUpdateStatus', jobsInfo: { __typename?: 'UpdaterJobsInfoType', isRunning: boolean, totalJobs: number, finishedJobs: number, skippedCategoriesCount: number, skippedMangasCount: number }, categoryUpdates: Array<{ __typename?: 'CategoryUpdateType', status: CategoryJobStatus, category: { __typename?: 'CategoryType', id: number, name: string } }>, mangaUpdates: Array<{ __typename?: 'MangaUpdateType', status: MangaJobStatus, manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null, unreadCount: number, downloadCount: number, bookmarkCount: number, hasDuplicateChapters: boolean, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, firstUnreadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, isRead: boolean, mangaId: number } | null, lastReadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, lastReadAt: string } | null, latestReadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, lastReadAt: string } | null, latestFetchedChapter?: { __typename?: 'ChapterType', id: number, fetchedAt: string } | null, latestUploadedChapter?: { __typename?: 'ChapterType', id: number, uploadDate: string } | null } }> } } | null }; export type StopUpdaterMutationVariables = Exact<{ input?: InputMaybe; @@ -3653,7 +3653,7 @@ export type GetSourceMigratableQuery = { __typename?: 'Query', source: { __typen export type GetSourcesListQueryVariables = Exact<{ [key: string]: never; }>; -export type GetSourcesListQuery = { __typename?: 'Query', sources: { __typename?: 'SourceNodeList', nodes: Array<{ __typename?: 'SourceType', lang: string, iconUrl: string, isNsfw: boolean, isConfigurable: boolean, supportsLatest: boolean, id: string, name: string, displayName: string, extension: { __typename?: 'ExtensionType', pkgName: string, repo?: string | null } }> } }; +export type GetSourcesListQuery = { __typename?: 'Query', sources: { __typename?: 'SourceNodeList', nodes: Array<{ __typename?: 'SourceType', lang: string, iconUrl: string, isNsfw: boolean, isConfigurable: boolean, supportsLatest: boolean, id: string, name: string, displayName: string, meta: Array<{ __typename?: 'SourceMetaType', sourceId: string, key: string, value: string }>, extension: { __typename?: 'ExtensionType', pkgName: string, repo?: string | null } }> } }; export type GetMigratableSourcesQueryVariables = Exact<{ [key: string]: never; }>; @@ -3681,7 +3681,7 @@ export type TrackerSearchQuery = { __typename?: 'Query', searchTracker: { __type export type GetUpdateStatusQueryVariables = Exact<{ [key: string]: never; }>; -export type GetUpdateStatusQuery = { __typename?: 'Query', libraryUpdateStatus: { __typename?: 'LibraryUpdateStatus', jobsInfo: { __typename?: 'UpdaterJobsInfoType', isRunning: boolean, totalJobs: number, finishedJobs: number, skippedCategoriesCount: number, skippedMangasCount: number }, categoryUpdates: Array<{ __typename?: 'CategoryUpdateType', status: CategoryJobStatus, category: { __typename?: 'CategoryType', id: number, name: string } }>, mangaUpdates: Array<{ __typename?: 'MangaUpdateType', status: MangaJobStatus, manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null } }> } }; +export type GetUpdateStatusQuery = { __typename?: 'Query', libraryUpdateStatus: { __typename?: 'LibraryUpdateStatus', jobsInfo: { __typename?: 'UpdaterJobsInfoType', isRunning: boolean, totalJobs: number, finishedJobs: number, skippedCategoriesCount: number, skippedMangasCount: number }, categoryUpdates: Array<{ __typename?: 'CategoryUpdateType', status: CategoryJobStatus, category: { __typename?: 'CategoryType', id: number, name: string } }>, mangaUpdates: Array<{ __typename?: 'MangaUpdateType', status: MangaJobStatus, manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null, unreadCount: number, downloadCount: number, bookmarkCount: number, hasDuplicateChapters: boolean, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, firstUnreadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, isRead: boolean, mangaId: number } | null, lastReadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, lastReadAt: string } | null, latestReadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, lastReadAt: string } | null, latestFetchedChapter?: { __typename?: 'ChapterType', id: number, fetchedAt: string } | null, latestUploadedChapter?: { __typename?: 'ChapterType', id: number, uploadDate: string } | null } }> } }; export type GetLastUpdateTimestampQueryVariables = Exact<{ [key: string]: never; }>; @@ -3705,4 +3705,4 @@ export type UpdaterSubscriptionVariables = Exact<{ }>; -export type UpdaterSubscription = { __typename?: 'Subscription', libraryUpdateStatusChanged: { __typename?: 'UpdaterUpdates', omittedUpdates: boolean, jobsInfo: { __typename?: 'UpdaterJobsInfoType', isRunning: boolean, totalJobs: number, finishedJobs: number, skippedCategoriesCount: number, skippedMangasCount: number }, categoryUpdates: Array<{ __typename?: 'CategoryUpdateType', status: CategoryJobStatus, category: { __typename?: 'CategoryType', id: number, name: string } }>, mangaUpdates: Array<{ __typename?: 'MangaUpdateType', status: MangaJobStatus, manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null } }> } }; +export type UpdaterSubscription = { __typename?: 'Subscription', libraryUpdateStatusChanged: { __typename?: 'UpdaterUpdates', omittedUpdates: boolean, jobsInfo: { __typename?: 'UpdaterJobsInfoType', isRunning: boolean, totalJobs: number, finishedJobs: number, skippedCategoriesCount: number, skippedMangasCount: number }, categoryUpdates: Array<{ __typename?: 'CategoryUpdateType', status: CategoryJobStatus, category: { __typename?: 'CategoryType', id: number, name: string } }>, mangaUpdates: Array<{ __typename?: 'MangaUpdateType', status: MangaJobStatus, manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null, unreadCount: number, downloadCount: number, bookmarkCount: number, hasDuplicateChapters: boolean, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, firstUnreadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, isRead: boolean, mangaId: number } | null, lastReadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, lastReadAt: string } | null, latestReadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, lastReadAt: string } | null, latestFetchedChapter?: { __typename?: 'ChapterType', id: number, fetchedAt: string } | null, latestUploadedChapter?: { __typename?: 'ChapterType', id: number, uploadDate: string } | null } }> } }; diff --git a/src/modules/core/utils/Languages.ts b/src/modules/core/utils/Languages.ts index 607fc73f..0568e415 100644 --- a/src/modules/core/utils/Languages.ts +++ b/src/modules/core/utils/Languages.ts @@ -14,12 +14,14 @@ export enum DefaultLanguage { ALL = 'all', OTHER = 'other', LOCAL_SOURCE = 'localsourcelang', + PINNED = 'pinned', } const DEFAULT_LANGUAGE_TO_TRANSLATION: Record = { [DefaultLanguage.ALL]: 'extension.language.all', [DefaultLanguage.OTHER]: 'extension.language.other', [DefaultLanguage.LOCAL_SOURCE]: 'extension.language.other', + [DefaultLanguage.PINNED]: 'global.label.pinned', }; type LanguageObject = ISOLanguage & { orgCode: string; isoCode: string }; diff --git a/src/modules/extension/Extensions.constants.ts b/src/modules/extension/Extensions.constants.ts index 86db007f..d8e61390 100644 --- a/src/modules/extension/Extensions.constants.ts +++ b/src/modules/extension/Extensions.constants.ts @@ -53,4 +53,5 @@ export const extensionLanguageToTranslationKey: { [state in ExtensionGroupState [DefaultLanguage.ALL]: 'extension.language.all', [DefaultLanguage.OTHER]: 'extension.language.other', [DefaultLanguage.LOCAL_SOURCE]: 'extension.language.other', + [DefaultLanguage.PINNED]: 'global.label.pinned', }; diff --git a/src/modules/extension/Extensions.utils.ts b/src/modules/extension/Extensions.utils.ts index 91407310..f9b54465 100644 --- a/src/modules/extension/Extensions.utils.ts +++ b/src/modules/extension/Extensions.utils.ts @@ -49,7 +49,7 @@ export const isExtensionState = (value: string): boolean => export const isExtensionStateOrLanguage = (languageCode: string): boolean => isExtensionState(languageCode) || - [DefaultLanguage.ALL, DefaultLanguage.OTHER, DefaultLanguage.LOCAL_SOURCE].includes( + [DefaultLanguage.ALL, DefaultLanguage.OTHER, DefaultLanguage.LOCAL_SOURCE, DefaultLanguage.PINNED].includes( languageCode as DefaultLanguage, ); diff --git a/src/modules/metadata/Metadata.constants.ts b/src/modules/metadata/Metadata.constants.ts index 0f87ecd7..f55d3900 100644 --- a/src/modules/metadata/Metadata.constants.ts +++ b/src/modules/metadata/Metadata.constants.ts @@ -93,6 +93,7 @@ const APP_METADATA_OBJECT: Record = { themeMode: undefined, shouldUsePureBlackMode: undefined, mangaGridItemWidth: undefined, + isPinned: undefined, }; export const VALID_APP_METADATA_KEYS = Object.keys(APP_METADATA_OBJECT); @@ -158,6 +159,7 @@ export const GLOBAL_METADATA_KEYS: AppMetadataKeys[] = [ // sources 'savedSearches', + 'isPinned', // themes 'customThemes', diff --git a/src/modules/source/Source.types.ts b/src/modules/source/Source.types.ts index befd2a45..69cba6c2 100644 --- a/src/modules/source/Source.types.ts +++ b/src/modules/source/Source.types.ts @@ -10,6 +10,7 @@ import { ExtensionType, GetSourceBrowseQuery, GetSourceSettingsQuery, + SourceMetaFieldsFragment, SourcePreferenceChangeInput, SourceType, } from '@/lib/graphql/generated/graphql.ts'; @@ -25,6 +26,7 @@ export type SavedSourceSearch = { query?: string; filters?: IPos[] }; export interface ISourceMetadata { savedSearches?: Record; + isPinned: boolean; } export type SourceFilters = GetSourceBrowseQuery['source']['filters'][number]; @@ -65,3 +67,4 @@ export type SourceLanguageInfo = Pick; export type SourceDisplayNameInfo = Pick; export type SourceNsfwInfo = Pick; export type SourceRepoInfo = { extension: Pick }; +export type SourceMetaInfo = { meta: SourceMetaFieldsFragment[] }; diff --git a/src/modules/source/components/SourceCard.tsx b/src/modules/source/components/SourceCard.tsx index b7880f85..febf6641 100644 --- a/src/modules/source/components/SourceCard.tsx +++ b/src/modules/source/components/SourceCard.tsx @@ -14,6 +14,9 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import Stack from '@mui/material/Stack'; +import PushPinIcon from '@mui/icons-material/PushPin'; +import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined'; +import IconButton from '@mui/material/IconButton'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { SourceContentType } from '@/modules/source/screens/SourceMangas.tsx'; import { GetSourcesListQuery } from '@/lib/graphql/generated/graphql.ts'; @@ -23,6 +26,10 @@ import { MUIUtil } from '@/lib/mui/MUI.util.ts'; import { Sources } from '@/modules/source/services/Sources.ts'; import { ListCardAvatar } from '@/modules/core/components/lists/cards/ListCardAvatar.tsx'; import { ListCardContent } from '@/modules/core/components/lists/cards/ListCardContent.tsx'; +import { CustomTooltip } from '@/modules/core/components/CustomTooltip.tsx'; +import { createUpdateSourceMetadata, useGetSourceMetadata } from '@/modules/source/services/SourceMetadata.ts'; +import { makeToast } from '@/modules/core/utils/Toast.ts'; +import { getErrorMessage } from '@/lib/HelperFunctions.ts'; interface IProps { source: GetSourcesListQuery['sources']['nodes'][number]; @@ -43,8 +50,14 @@ export const SourceCard: React.FC = (props: IProps) => { extension: { repo }, } = source; + const { isPinned } = useGetSourceMetadata(source); + const sourceName = Sources.isLocalSource(source) ? t('source.local_source.title') : name; + const updateSetting = createUpdateSourceMetadata(source, (e) => + makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)), + ); + return ( = (props: IProps) => { {t('global.button.latest')} )} + + { + e.preventDefault(); + updateSetting('isPinned', !isPinned); + }} + color={isPinned ? 'primary' : 'inherit'} + > + {isPinned ? : } + + diff --git a/src/modules/source/services/SourceMetadata.ts b/src/modules/source/services/SourceMetadata.ts index 666966d7..e38a701d 100644 --- a/src/modules/source/services/SourceMetadata.ts +++ b/src/modules/source/services/SourceMetadata.ts @@ -22,6 +22,7 @@ import { const DEFAULT_SOURCE_METADATA: ISourceMetadata = { savedSearches: undefined, + isPinned: false, }; const convertAppMetadataToGqlMetadata = ( diff --git a/src/modules/source/services/Sources.ts b/src/modules/source/services/Sources.ts index a8dec9a4..4d8ea28c 100644 --- a/src/modules/source/services/Sources.ts +++ b/src/modules/source/services/Sources.ts @@ -11,6 +11,7 @@ import { SourceIdInfo, SourceLanguageInfo, SourceNsfwInfo, + SourceMetaInfo, SourceRepoInfo, } from '@/modules/source/Source.types.ts'; import { @@ -20,6 +21,7 @@ import { toComparableLanguages, toUniqueLanguageCodes, } from '@/modules/core/utils/Languages.ts'; +import { getSourceMetadata } from '@/modules/source/services/SourceMetadata.ts'; export class Sources { static readonly LOCAL_SOURCE_ID = '0'; @@ -40,13 +42,30 @@ export class Sources { return [...new Set(sources.map(Sources.getLanguage))]; } - static groupByLanguage( + static groupByLanguage( sources: Source[], ): Record { - const sourcesByLanguage = Object.groupBy(sources, Sources.getLanguage); - const sourcesBySortedLanguage = Object.entries(sourcesByLanguage).toSorted(([a], [b]) => - languageSpecialSortComparator(a, b), - ); + const sourcesByLanguage = Object.groupBy(sources, (source) => { + if (getSourceMetadata(source).isPinned) { + return DefaultLanguage.PINNED; + } + + return Sources.getLanguage(source); + }); + const sourcesBySortedLanguage = Object.entries(sourcesByLanguage).toSorted(([a], [b]) => { + const isAPinned = a === DefaultLanguage.PINNED; + const isBPinned = b === DefaultLanguage.PINNED; + + if (isAPinned) { + return -1; + } + + if (isBPinned) { + return 1; + } + + return languageSpecialSortComparator(a, b); + }); const sortedSourcesBySortedLanguage = sourcesBySortedLanguage.map(([language, sourcesOfLanguage]) => [ language, (sourcesOfLanguage ?? []).toSorted((a, b) => a.displayName.localeCompare(b.displayName)),