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