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:
@@ -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 = (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user