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

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

View File

@@ -43,6 +43,7 @@ export interface LibraryOptions {
hasDuplicateChapters: NullAndUndefined<boolean>;
hasTrackerBinding: Record<TrackerIdInfo['id'], NullAndUndefined<boolean>>;
hasStatus: Record<MangaStatus, NullAndUndefined<boolean>>;
hasSource: Record<string, NullAndUndefined<boolean>>;
}
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 { useLingui } from '@lingui/react/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 { RadioInput } from '@/base/components/inputs/RadioInput.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 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 updateCategoryLibraryOptions = createUpdateCategoryMetadata(category, (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.hasDuplicateChapters != 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 (
<>

View File

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

View File

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

View File

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