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],
);
};

View File

@@ -20,6 +20,7 @@ export type SelectableCollectionReturnType<Id extends number | string, Key exten
setSelectionForKey: (key: Key, ids: Id[]) => void;
getSelectionForKey: (key: Key) => Id[];
clearSelection: () => void;
reset: () => void;
};
export const useSelectableCollection = <Id extends number | string, Key extends string = 'default'>(
@@ -122,6 +123,11 @@ export const useSelectableCollection = <Id extends number | string, Key extends
setKeyToSelectedItemIds({});
}, []);
const reset = useCallback(() => {
clearSelection();
setKeyToSelectedItemIds(initialState);
}, [clearSelection, initialState]);
return {
selectedItemIds,
keySelectedItemIds,
@@ -134,5 +140,6 @@ export const useSelectableCollection = <Id extends number | string, Key extends
setSelectionForKey,
getSelectionForKey,
clearSelection,
reset,
};
};

View File

@@ -0,0 +1,115 @@
/*
* 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 Button from '@mui/material/Button';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Dialog from '@mui/material/Dialog';
import FormGroup from '@mui/material/FormGroup';
import { useTranslation } from 'react-i18next';
import Stack from '@mui/material/Stack';
import { useCallback, useMemo } from 'react';
import { CheckboxInput } from '@/modules/core/components/inputs/CheckboxInput.tsx';
import { useSelectableCollection } from '@/modules/collection/hooks/useSelectableCollection.ts';
export function CheckboxListSetting<Item>({
items,
getId,
getLabel,
isChecked,
open,
onClose,
}: {
items: Item[];
getId: (item: Item) => string;
getLabel: (item: Item) => string;
isChecked: (item: Item) => boolean;
open: boolean;
onClose: (selectedItems?: Item[]) => void;
}) {
const { t } = useTranslation();
const itemIds = useMemo(() => items.map(getId), [items]);
const currentSelectedItemIds = useMemo(() => items.filter(isChecked).map(getId), [items]);
const { selectedItemIds, handleSelection, handleSelectAll, reset } = useSelectableCollection(items.length, {
currentKey: 'default',
itemIds,
initialState: { default: currentSelectedItemIds },
});
const handleCancel = () => {
onClose();
reset();
};
const handleOk = useCallback(() => {
const didSelectionChange =
selectedItemIds.length !== currentSelectedItemIds.length ||
selectedItemIds.some((id) => !currentSelectedItemIds.includes(id));
const selectedItems = items.filter((item) => selectedItemIds.includes(getId(item)));
onClose(didSelectionChange ? selectedItems : undefined);
}, [selectedItemIds]);
return (
<Dialog
sx={{
'.MuiDialog-paper': {
maxHeight: 435,
width: '80%',
},
}}
maxWidth="xs"
open={open}
onClose={handleCancel}
>
<DialogTitle>{t('category.title.set_categories')}</DialogTitle>
<DialogContent dividers>
<FormGroup>
{items.length === 0 && <span>{t('category.error.no_categories_found.label.info')}</span>}
{items.map((item) => (
<CheckboxInput
checked={selectedItemIds.includes(getId(item))}
onChange={(e, checked) => handleSelection(getId(item), checked)}
label={getLabel(item)}
key={getId(item)}
/>
))}
</FormGroup>
</DialogContent>
<DialogActions>
<Stack sx={{ width: '100%' }}>
<Stack
direction="row"
sx={{
justifyContent: 'space-between',
alignItems: 'end',
width: '100%',
}}
>
<Button onClick={() => handleSelectAll(!selectedItemIds.length, items.map(getId))}>
{t(selectedItemIds.length ? 'global.button.reset' : 'global.button.select_all')}
</Button>
<Stack direction="row">
<Button autoFocus onClick={handleCancel} color="primary">
{t('global.button.cancel')}
</Button>
{!!items.length && (
<Button onClick={handleOk} color="primary">
{t('global.button.ok')}
</Button>
)}
</Stack>
</Stack>
</Stack>
</DialogActions>
</Dialog>
);
}

View File

@@ -20,6 +20,7 @@ import {
import { convertFromGqlMeta } from '@/modules/metadata/services/MetadataConverter.ts';
import { requestUpdateMangaMetadata } from '@/modules/metadata/services/MetadataUpdater.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { jsonSaveParse } from '@/lib/HelperFunctions.ts';
const DEFAULT_MANGA_METADATA: MangaMetadata = {
...DEFAULT_CHAPTER_OPTIONS,
@@ -29,12 +30,14 @@ const convertAppMetadataToGqlMetadata = (
metadata: Partial<MangaMetadata>,
): Metadata<string, AllowedMetadataValueTypes> => ({
...metadata,
excludedScanlators: JSON.stringify(metadata.excludedScanlators),
});
const convertGqlMetadataToAppMetadata = (
metadata: Partial<Metadata<AppMetadataKeys, AllowedMetadataValueTypes>>,
): MangaMetadata => ({
...(metadata as unknown as MangaMetadata),
excludedScanlators: jsonSaveParse<MangaMetadata['excludedScanlators']>(metadata.excludedScanlators as string) ?? [],
});
const getMangaMetadataWithDefaultValueFallback = (

View File

@@ -98,6 +98,7 @@ export const APP_METADATA_KEY_TO_TYPE = {
isPinned: 'boolean',
lastUsedSourceId: 'string',
shouldShowOnlySourcesWithResults: 'boolean',
excludedScanlators: 'string', // string[]
} as const satisfies Record<AppMetadataKeys, 'auto' | 'string' | 'number' | 'boolean'>;
export const VALID_APP_METADATA_KEYS = Object.keys(APP_METADATA_KEY_TO_TYPE);