feat: add Source filter to library filtering system (#1131)

* feat: add Source filter to library filtering system

Add a new Source filter section to the library options panel that allows
users to filter their library manga by source (e.g. MangaDex, MangaSee).

- Add hasSource to LibraryOptions type and category metadata
- Add source filtering logic in useGetVisibleLibraryMangas
- Add Source section with three-state checkboxes in filter UI
- Show loading spinner while source list loads
- Include source filter in active filter indicator

Closes #1057

* refactor: address review feedback for source filter

- Remove skip option, let query always run
- Use uniqBy from lodash/fp for deduplication
- Remove loading spinner (data available quickly)
- Wrap source section in length check

* docs: add source filter to changelog

---------

Co-authored-by: Copilot <noreply@github.com>
This commit is contained in:
Sean Smith
2026-07-20 15:09:06 -07:00
committed by GitHub
parent 98ee3d46c8
commit 8b8da30335
8 changed files with 58 additions and 3 deletions

View File

@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added ### Added
- (**Library**) Add source filter to library filtering options
- (**Migration**) Add a search option to ignore outdated matches - (**Migration**) Add a search option to ignore outdated matches
- (**Migration**) Add a search option to ignore matches with missing chapters - (**Migration**) Add a search option to ignore matches with missing chapters
- (**Migration**) Add "local source" as a possible destination source - (**Migration**) Add "local source" as a possible destination source

View File

@@ -36,6 +36,7 @@ export const DEFAULT_CATEGORY_METADATA: ICategoryMetadata = {
hasDuplicateChapters: undefined, hasDuplicateChapters: undefined,
hasTrackerBinding: {}, hasTrackerBinding: {},
hasStatus: {} as LibraryOptions['hasStatus'], hasStatus: {} as LibraryOptions['hasStatus'],
hasSource: {},
}; };
const convertAppMetadataToGqlMetadata = ( const convertAppMetadataToGqlMetadata = (
@@ -44,6 +45,7 @@ const convertAppMetadataToGqlMetadata = (
...metadata, ...metadata,
hasTrackerBinding: metadata.hasTrackerBinding ? JSON.stringify(metadata.hasTrackerBinding) : undefined, hasTrackerBinding: metadata.hasTrackerBinding ? JSON.stringify(metadata.hasTrackerBinding) : undefined,
hasStatus: metadata.hasStatus ? JSON.stringify(metadata.hasStatus) : undefined, hasStatus: metadata.hasStatus ? JSON.stringify(metadata.hasStatus) : undefined,
hasSource: metadata.hasSource ? JSON.stringify(metadata.hasSource) : undefined,
}); });
const getCategoryMetadataWithDefaultValueFallback = ( const getCategoryMetadataWithDefaultValueFallback = (

View File

@@ -43,6 +43,7 @@ export interface LibraryOptions {
hasDuplicateChapters: NullAndUndefined<boolean>; hasDuplicateChapters: NullAndUndefined<boolean>;
hasTrackerBinding: Record<TrackerIdInfo['id'], NullAndUndefined<boolean>>; hasTrackerBinding: Record<TrackerIdInfo['id'], NullAndUndefined<boolean>>;
hasStatus: Record<MangaStatus, NullAndUndefined<boolean>>; hasStatus: Record<MangaStatus, NullAndUndefined<boolean>>;
hasSource: Record<string, NullAndUndefined<boolean>>;
} }
export type TMangaDuplicate = MangaIdInfo & MangaTitleInfo & MangaDescriptionInfo; export type TMangaDuplicate = MangaIdInfo & MangaTitleInfo & MangaDescriptionInfo;

View File

@@ -11,6 +11,8 @@ import FormLabel from '@mui/material/FormLabel';
import RadioGroup from '@mui/material/RadioGroup'; import RadioGroup from '@mui/material/RadioGroup';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useMemo } from 'react';
import uniqBy from 'lodash/fp/uniqBy';
import { CheckboxInput } from '@/base/components/inputs/CheckboxInput.tsx'; import { CheckboxInput } from '@/base/components/inputs/CheckboxInput.tsx';
import { RadioInput } from '@/base/components/inputs/RadioInput.tsx'; import { RadioInput } from '@/base/components/inputs/RadioInput.tsx';
import { SortRadioInput } from '@/base/components/inputs/SortRadioInput.tsx'; import { SortRadioInput } from '@/base/components/inputs/SortRadioInput.tsx';
@@ -65,6 +67,16 @@ export const LibraryOptionsPanel = ({
const trackerList = requestManager.useGetTrackerList<GetTrackersSettingsQuery>(GET_TRACKERS_SETTINGS); const trackerList = requestManager.useGetTrackerList<GetTrackersSettingsQuery>(GET_TRACKERS_SETTINGS);
const loggedInTrackers = Trackers.getLoggedIn(trackerList.data?.trackers.nodes ?? STABLE_EMPTY_ARRAY); const loggedInTrackers = Trackers.getLoggedIn(trackerList.data?.trackers.nodes ?? STABLE_EMPTY_ARRAY);
const migratableSourcesResult = requestManager.useGetMigratableSources();
const librarySources = useMemo(() => {
const sources = migratableSourcesResult.data?.mangas.nodes
.map(({ source }) => source)
.filter((source) => source != null);
const uniqueSources = uniqBy('id', sources);
return uniqueSources.toSorted((a, b) => a.displayName.localeCompare(b.displayName));
}, [migratableSourcesResult.data?.mangas.nodes]);
const categoryLibraryOptions = useGetCategoryMetadata(category); const categoryLibraryOptions = useGetCategoryMetadata(category);
const updateCategoryLibraryOptions = createUpdateCategoryMetadata(category, (e) => const updateCategoryLibraryOptions = createUpdateCategoryMetadata(category, (e) =>
makeToast(t`Failed to save changes`, 'error', getErrorMessage(e)), makeToast(t`Failed to save changes`, 'error', getErrorMessage(e)),
@@ -140,6 +152,24 @@ export const LibraryOptionsPanel = ({
} }
/> />
))} ))}
{librarySources.length > 0 && (
<>
<FormLabel sx={{ mt: 2 }}>{t`Source`}</FormLabel>
{librarySources.map((source) => (
<ThreeStateCheckboxInput
key={source.id}
label={source.displayName}
checked={categoryLibraryOptions.hasSource[source.id]}
onChange={(checked) =>
updateCategoryLibraryOptions('hasSource', {
...categoryLibraryOptions.hasSource,
[source.id]: checked,
})
}
/>
))}
</>
)}
</> </>
); );
} }

View File

@@ -31,7 +31,8 @@ export const LibraryToolbarMenu = ({
options.hasBookmarkedChapters != null || options.hasBookmarkedChapters != null ||
options.hasDuplicateChapters != null || options.hasDuplicateChapters != null ||
Object.values(options.hasStatus).some((hasStatus) => hasStatus != null) || Object.values(options.hasStatus).some((hasStatus) => hasStatus != null) ||
Object.values(options.hasTrackerBinding).some((trackerFilterStatus) => trackerFilterStatus != null); Object.values(options.hasTrackerBinding).some((trackerFilterStatus) => trackerFilterStatus != null) ||
Object.values(options.hasSource).some((sourceFilterStatus) => sourceFilterStatus != null);
return ( return (
<> <>

View File

@@ -117,6 +117,11 @@ const statusFilter = (statusFilters: LibraryOptions['hasStatus'], manga: MangaSt
.map(([status, statusFilterState]) => triStateFilterBoolean(statusFilterState, status === manga.status)) .map(([status, statusFilterState]) => triStateFilterBoolean(statusFilterState, status === manga.status))
.every(Boolean); .every(Boolean);
const sourceFilter = (sourceFilters: LibraryOptions['hasSource'], manga: MangaSourceIdInfo): boolean =>
Object.entries(sourceFilters)
.map(([sourceId, sourceFilterState]) => triStateFilterBoolean(sourceFilterState, sourceId === manga.sourceId))
.every(Boolean);
type TMangaFilterOptions = Pick< type TMangaFilterOptions = Pick<
LibraryOptions, LibraryOptions,
| 'hasUnreadChapters' | 'hasUnreadChapters'
@@ -126,10 +131,12 @@ type TMangaFilterOptions = Pick<
| 'hasDuplicateChapters' | 'hasDuplicateChapters'
| 'hasTrackerBinding' | 'hasTrackerBinding'
| 'hasStatus' | 'hasStatus'
| 'hasSource'
>; >;
type TMangaFilter = Pick<MangaType, 'bookmarkCount' | 'hasDuplicateChapters'> & type TMangaFilter = Pick<MangaType, 'bookmarkCount' | 'hasDuplicateChapters'> &
TMangaTrackerFilter & TMangaTrackerFilter &
MangaStatusInfo & MangaStatusInfo &
MangaSourceIdInfo &
MangaChapterCountInfo & MangaChapterCountInfo &
MangaDownloadInfo & MangaDownloadInfo &
MangaUnreadInfo; MangaUnreadInfo;
@@ -143,6 +150,7 @@ const filterManga = (
hasDuplicateChapters, hasDuplicateChapters,
hasTrackerBinding, hasTrackerBinding,
hasStatus, hasStatus,
hasSource,
}: TMangaFilterOptions, }: TMangaFilterOptions,
): boolean => ): boolean =>
triStateFilterNumber(hasDownloadedChapters, manga.downloadCount) && triStateFilterNumber(hasDownloadedChapters, manga.downloadCount) &&
@@ -151,7 +159,8 @@ const filterManga = (
triStateFilterNumber(hasBookmarkedChapters, manga.bookmarkCount) && triStateFilterNumber(hasBookmarkedChapters, manga.bookmarkCount) &&
triStateFilterBoolean(hasDuplicateChapters, manga.hasDuplicateChapters) && triStateFilterBoolean(hasDuplicateChapters, manga.hasDuplicateChapters) &&
trackerFilter(hasTrackerBinding, manga) && trackerFilter(hasTrackerBinding, manga) &&
statusFilter(hasStatus, manga); statusFilter(hasStatus, manga) &&
sourceFilter(hasSource, manga);
type TMangasFilter = TMangaQueryFilter & TMangaFilter; type TMangasFilter = TMangaQueryFilter & TMangaFilter;
const filterMangas = <Manga extends TMangasFilter>( const filterMangas = <Manga extends TMangasFilter>(
@@ -243,6 +252,7 @@ export const useGetVisibleLibraryMangas = <Manga extends MangaIdInfo & TMangasFi
hasTrackerBinding, hasTrackerBinding,
hasDuplicateChapters, hasDuplicateChapters,
hasStatus, hasStatus,
hasSource,
} = options; } = options;
const { settings } = useMetadataServerSettings(); const { settings } = useMetadataServerSettings();
@@ -262,6 +272,7 @@ export const useGetVisibleLibraryMangas = <Manga extends MangaIdInfo & TMangasFi
hasTrackerBinding, hasTrackerBinding,
hasDuplicateChapters, hasDuplicateChapters,
hasStatus, hasStatus,
hasSource,
settings.ignoreFilters, settings.ignoreFilters,
], ],
); );
@@ -273,13 +284,17 @@ export const useGetVisibleLibraryMangas = <Manga extends MangaIdInfo & TMangasFi
const isATrackFilterActive = Object.values(options.hasTrackerBinding).some( const isATrackFilterActive = Object.values(options.hasTrackerBinding).some(
(trackFilterState) => trackFilterState != null, (trackFilterState) => trackFilterState != null,
); );
const isASourceFilterActive = Object.values(options.hasSource).some(
(sourceFilterState) => sourceFilterState != null,
);
const showFilteredOutMessage = const showFilteredOutMessage =
(hasUnreadChapters != null || (hasUnreadChapters != null ||
hasReadChapters != null || hasReadChapters != null ||
hasDownloadedChapters != null || hasDownloadedChapters != null ||
hasBookmarkedChapters != null || hasBookmarkedChapters != null ||
!!query || !!query ||
isATrackFilterActive) && isATrackFilterActive ||
isASourceFilterActive) &&
filteredMangas.length === 0 && filteredMangas.length === 0 &&
mangas.length > 0; mangas.length > 0;

View File

@@ -200,6 +200,9 @@ export const APP_METADATA: Record<
hasStatus: { hasStatus: {
convert: convertToObject<LibraryOptions['hasStatus']>, convert: convertToObject<LibraryOptions['hasStatus']>,
}, },
hasSource: {
convert: convertToObject<LibraryOptions['hasSource']>,
},
customThemes: { customThemes: {
convert: convertToObject<MetadataThemeSettings['customThemes']>, convert: convertToObject<MetadataThemeSettings['customThemes']>,
}, },
@@ -440,6 +443,7 @@ export const GLOBAL_METADATA_KEYS: AppMetadataKeys[] = [
'hasDuplicateChapters', 'hasDuplicateChapters',
'hasTrackerBinding', 'hasTrackerBinding',
'hasStatus', 'hasStatus',
'hasSource',
// sort // sort
'sortBy', 'sortBy',
'sortDesc', 'sortDesc',

View File

@@ -3504,6 +3504,7 @@ msgstr "Sort"
#: src/features/browse/screens/Browse.tsx #: src/features/browse/screens/Browse.tsx
#: src/features/browse/screens/BrowseSettings.tsx #: src/features/browse/screens/BrowseSettings.tsx
#: src/features/library/components/LibraryOptionsPanel.tsx
#: src/features/manga/components/details/MangaDetails.tsx #: src/features/manga/components/details/MangaDetails.tsx
#: src/features/source/browse/screens/SourceMangas.tsx #: src/features/source/browse/screens/SourceMangas.tsx
msgid "Source" msgid "Source"