Add scanlator filter to chapter list

closes #937
This commit is contained in:
schroda
2025-06-09 15:32:04 +02:00
parent 645eaeb315
commit 7c0ffe9bd3
13 changed files with 234 additions and 10 deletions

View File

@@ -18,6 +18,7 @@ export const DEFAULT_CHAPTER_OPTIONS: ChapterListOptions = {
reverse: true,
sortBy: 'source',
showChapterNumber: false,
excludedScanlators: [],
};
export const CHAPTER_SORT_OPTIONS_TO_TRANSLATION_KEY: Record<ChapterSortMode, TranslationKey> = {

View File

@@ -22,6 +22,7 @@ export interface ChapterListOptions {
reverse: boolean;
sortBy: ChapterSortMode;
showChapterNumber: boolean;
excludedScanlators: string[];
}
export type TChapterReader = ChapterReaderFieldsFragment;

View File

@@ -0,0 +1,52 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { bindTrigger, usePopupState } from 'material-ui-popup-state/hooks';
import { useTranslation } from 'react-i18next';
import PeopleAltOutlinedIcon from '@mui/icons-material/PeopleAltOutlined';
import { CheckboxListSetting } from '@/modules/core/components/settings/CheckboxListSetting.tsx';
import { updateChapterListOptions } from '@/modules/chapter/utils/ChapterList.util.tsx';
import { CheckboxInput } from '@/modules/core/components/inputs/CheckboxInput.tsx';
export const ChapterExcludeSanlatorsFilter = ({
updateOption,
scanlators,
excludedScanlators,
}: {
updateOption: ReturnType<typeof updateChapterListOptions>;
scanlators: string[];
excludedScanlators: string[];
}) => {
const { t } = useTranslation();
const popupState = usePopupState({ variant: 'dialog', popupId: 'chapter-list-options-scanlator-filter-dialog' });
return (
<>
<CheckboxInput
{...bindTrigger(popupState)}
label={t('global.label.scanlator')}
icon={<PeopleAltOutlinedIcon />}
checkedIcon={<PeopleAltOutlinedIcon color="warning" />}
checked={!!excludedScanlators.length}
/>
<CheckboxListSetting
open={popupState.isOpen}
onClose={(selectedScanlators) => {
if (selectedScanlators) {
updateOption('excludedScanlators', selectedScanlators);
}
popupState.close();
}}
items={scanlators}
getId={(scanlator) => scanlator}
getLabel={(scanlator) => scanlator}
isChecked={(scanlator) => excludedScanlators.includes(scanlator)}
/>
</>
);
};

View File

@@ -216,6 +216,8 @@ export const ChapterList = ({
options={options}
updateOption={updateOption}
unreadChapters={Chapters.getNonRead(chapters)}
scanlators={Chapters.getScanlators(chapters)}
excludeScanlators={options.excludedScanlators}
/>
)}
{!!visibleChapterIds.length && (

View File

@@ -17,12 +17,15 @@ import { CHAPTER_SORT_OPTIONS_TO_TRANSLATION_KEY } from '@/modules/chapter/Chapt
import { TranslationKey } from '@/Base.types.ts';
import { ChapterListOptions } from '@/modules/chapter/Chapter.types.ts';
import { updateChapterListOptions } from '@/modules/chapter/utils/ChapterList.util.tsx';
import { ChapterExcludeSanlatorsFilter } from '@/modules/chapter/components/ChapterExcludeSanlatorsFilter.tsx';
interface IProps {
open: boolean;
onClose: () => void;
options: ChapterListOptions;
updateOption: ReturnType<typeof updateChapterListOptions>;
scanlators: string[];
excludedScanlators: string[];
}
const TITLES: { [key in 'filter' | 'sort' | 'display']: TranslationKey } = {
@@ -31,7 +34,14 @@ const TITLES: { [key in 'filter' | 'sort' | 'display']: TranslationKey } = {
display: 'global.label.display',
};
export const ChapterOptions: React.FC<IProps> = ({ open, onClose, options, updateOption }) => {
export const ChapterOptions: React.FC<IProps> = ({
open,
onClose,
options,
updateOption,
scanlators,
excludedScanlators,
}) => {
const { t } = useTranslation();
return (
@@ -60,6 +70,11 @@ export const ChapterOptions: React.FC<IProps> = ({ open, onClose, options, updat
checked={options.bookmarked}
onChange={(c) => updateOption('bookmarked', c)}
/>
<ChapterExcludeSanlatorsFilter
scanlators={scanlators}
excludedScanlators={excludedScanlators}
updateOption={updateOption}
/>
</>
);
}

View File

@@ -33,6 +33,8 @@ interface IProps {
options: ChapterListOptions;
updateOption: ReturnType<typeof updateChapterListOptions>;
unreadChapters: (ChapterIdInfo & ChapterDownloadInfo & ChapterBookmarkInfo)[];
scanlators: string[];
excludeScanlators: string[];
}
export const ChaptersToolbarMenu = ({
@@ -42,6 +44,8 @@ export const ChaptersToolbarMenu = ({
options,
updateOption,
unreadChapters,
scanlators,
excludeScanlators,
}: IProps) => {
const { t } = useTranslation();
@@ -90,7 +94,14 @@ export const ChaptersToolbarMenu = ({
<FilterList color={isFiltered ? 'warning' : undefined} />
</IconButton>
</CustomTooltip>
<ChapterOptions open={open} onClose={() => setOpen(false)} options={options} updateOption={updateOption} />
<ChapterOptions
open={open}
onClose={() => setOpen(false)}
options={options}
updateOption={updateOption}
scanlators={scanlators}
excludedScanlators={excludeScanlators}
/>
</>
);
};

View File

@@ -439,4 +439,12 @@ export class Chapters {
return Math.max(0, Math.floor(higherChapterNumber) - Math.floor(lowerChapterNumber) - 1);
}
static getScanlators<Chapter extends ChapterScanlatorInfo>(chapters: Chapter[]): string[] {
return [
...new Set(
chapters.map((chapter) => chapter.scanlator).filter((scanlator) => typeof scanlator === 'string'),
),
];
}
}

View File

@@ -14,6 +14,7 @@ import {
ChapterDownloadInfo,
ChapterListOptions,
ChapterReadInfo,
ChapterScanlatorInfo,
} from '@/modules/chapter/Chapter.types.ts';
import { MangaIdInfo } from '@/modules/manga/Manga.types.ts';
import { GqlMetaHolder } from '@/modules/metadata/Metadata.types.ts';
@@ -56,6 +57,10 @@ function bookmarkedFilter(
}
}
function scanlatorFilter(excludedScanlators: string[], { scanlator }: ChapterScanlatorInfo): boolean {
return !!scanlator && !excludedScanlators.includes(scanlator);
}
type TChapterSort = Pick<ChapterType, 'sourceOrder' | 'fetchedAt' | 'chapterNumber' | 'uploadDate'>;
const sortChapters = <T extends TChapterSort>(
chapters: T[],
@@ -87,7 +92,7 @@ const sortChapters = <T extends TChapterSort>(
return sortedChapters;
};
type TChapterFilter = TChapterSort & ChapterReadInfo & ChapterDownloadInfo & ChapterBookmarkInfo;
type TChapterFilter = TChapterSort & ChapterReadInfo & ChapterDownloadInfo & ChapterBookmarkInfo & ChapterScanlatorInfo;
export function filterAndSortChapters<Chapters extends TChapterFilter>(
chapters: Chapters[],
options: ChapterListOptions,
@@ -96,23 +101,25 @@ export function filterAndSortChapters<Chapters extends TChapterFilter>(
(chp) =>
unreadFilter(options.unread, chp) &&
downloadFilter(options.downloaded, chp) &&
bookmarkedFilter(options.bookmarked, chp),
bookmarkedFilter(options.bookmarked, chp) &&
scanlatorFilter(options.excludedScanlators, chp),
);
return sortChapters(filtered, options);
}
export const isFilterActive = (options: ChapterListOptions) => {
const { unread, downloaded, bookmarked } = options;
return unread != null || downloaded != null || bookmarked != null;
const { unread, downloaded, bookmarked, excludedScanlators } = options;
return unread != null || downloaded != null || bookmarked != null || !!excludedScanlators.length;
};
export const useChapterListOptions = (manga: MangaIdInfo & GqlMetaHolder): ChapterListOptions => {
const { unread, downloaded, bookmarked, reverse, sortBy, showChapterNumber } = useGetMangaMetadata(manga);
const { unread, downloaded, bookmarked, reverse, sortBy, showChapterNumber, excludedScanlators } =
useGetMangaMetadata(manga);
return useMemo(
() => ({ unread, downloaded, bookmarked, reverse, sortBy, showChapterNumber }),
[unread, downloaded, bookmarked, reverse, sortBy, showChapterNumber],
() => ({ unread, downloaded, bookmarked, reverse, sortBy, showChapterNumber, excludedScanlators }),
[unread, downloaded, bookmarked, reverse, sortBy, showChapterNumber, excludedScanlators],
);
};