diff --git a/public/locales/en.json b/public/locales/en.json index bb190246..89832761 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -458,6 +458,7 @@ "placeholder": "Placeholder", "previous": "Previous", "right": "Right", + "scanlator": "Scanlator", "share": "Share", "small": "Small", "sort": "Sort", @@ -965,6 +966,7 @@ }, "label": { "behaviour": "Behaviour", + "custom_scroll_amount": "Custom scroll amount", "fit_page_to_window": "Fit page to window", "layout": "Layout", "limit_reader_width": "Limit reader width", @@ -977,7 +979,6 @@ "reading_mode": "Reading mode", "show_page_number": "Show page number", "skip_dup_chapters": "Skip duplicate chapters", - "custom_scroll_amount": "Custom scroll amount", "static_navigation": "Static navigation" }, "overlay_mode": "Overlay mode", diff --git a/src/modules/chapter/Chapter.constants.ts b/src/modules/chapter/Chapter.constants.ts index 22d75713..ac306d02 100644 --- a/src/modules/chapter/Chapter.constants.ts +++ b/src/modules/chapter/Chapter.constants.ts @@ -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 = { diff --git a/src/modules/chapter/Chapter.types.ts b/src/modules/chapter/Chapter.types.ts index 6f6f58c7..142a3f2d 100644 --- a/src/modules/chapter/Chapter.types.ts +++ b/src/modules/chapter/Chapter.types.ts @@ -22,6 +22,7 @@ export interface ChapterListOptions { reverse: boolean; sortBy: ChapterSortMode; showChapterNumber: boolean; + excludedScanlators: string[]; } export type TChapterReader = ChapterReaderFieldsFragment; diff --git a/src/modules/chapter/components/ChapterExcludeSanlatorsFilter.tsx b/src/modules/chapter/components/ChapterExcludeSanlatorsFilter.tsx new file mode 100644 index 00000000..5509424c --- /dev/null +++ b/src/modules/chapter/components/ChapterExcludeSanlatorsFilter.tsx @@ -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; + scanlators: string[]; + excludedScanlators: string[]; +}) => { + const { t } = useTranslation(); + const popupState = usePopupState({ variant: 'dialog', popupId: 'chapter-list-options-scanlator-filter-dialog' }); + + return ( + <> + } + checkedIcon={} + checked={!!excludedScanlators.length} + /> + { + if (selectedScanlators) { + updateOption('excludedScanlators', selectedScanlators); + } + popupState.close(); + }} + items={scanlators} + getId={(scanlator) => scanlator} + getLabel={(scanlator) => scanlator} + isChecked={(scanlator) => excludedScanlators.includes(scanlator)} + /> + + ); +}; diff --git a/src/modules/chapter/components/ChapterList.tsx b/src/modules/chapter/components/ChapterList.tsx index dae6290c..5eebeede 100644 --- a/src/modules/chapter/components/ChapterList.tsx +++ b/src/modules/chapter/components/ChapterList.tsx @@ -216,6 +216,8 @@ export const ChapterList = ({ options={options} updateOption={updateOption} unreadChapters={Chapters.getNonRead(chapters)} + scanlators={Chapters.getScanlators(chapters)} + excludeScanlators={options.excludedScanlators} /> )} {!!visibleChapterIds.length && ( diff --git a/src/modules/chapter/components/ChapterOptions.tsx b/src/modules/chapter/components/ChapterOptions.tsx index 2a0b64f4..0815d92d 100644 --- a/src/modules/chapter/components/ChapterOptions.tsx +++ b/src/modules/chapter/components/ChapterOptions.tsx @@ -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; + 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 = ({ open, onClose, options, updateOption }) => { +export const ChapterOptions: React.FC = ({ + open, + onClose, + options, + updateOption, + scanlators, + excludedScanlators, +}) => { const { t } = useTranslation(); return ( @@ -60,6 +70,11 @@ export const ChapterOptions: React.FC = ({ open, onClose, options, updat checked={options.bookmarked} onChange={(c) => updateOption('bookmarked', c)} /> + ); } diff --git a/src/modules/chapter/components/ChaptersToolbarMenu.tsx b/src/modules/chapter/components/ChaptersToolbarMenu.tsx index e387babd..fb6a04f9 100644 --- a/src/modules/chapter/components/ChaptersToolbarMenu.tsx +++ b/src/modules/chapter/components/ChaptersToolbarMenu.tsx @@ -33,6 +33,8 @@ interface IProps { options: ChapterListOptions; updateOption: ReturnType; 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 = ({ - setOpen(false)} options={options} updateOption={updateOption} /> + setOpen(false)} + options={options} + updateOption={updateOption} + scanlators={scanlators} + excludedScanlators={excludeScanlators} + /> ); }; diff --git a/src/modules/chapter/services/Chapters.ts b/src/modules/chapter/services/Chapters.ts index 735231b3..66bab4ac 100644 --- a/src/modules/chapter/services/Chapters.ts +++ b/src/modules/chapter/services/Chapters.ts @@ -439,4 +439,12 @@ export class Chapters { return Math.max(0, Math.floor(higherChapterNumber) - Math.floor(lowerChapterNumber) - 1); } + + static getScanlators(chapters: Chapter[]): string[] { + return [ + ...new Set( + chapters.map((chapter) => chapter.scanlator).filter((scanlator) => typeof scanlator === 'string'), + ), + ]; + } } diff --git a/src/modules/chapter/utils/ChapterList.util.tsx b/src/modules/chapter/utils/ChapterList.util.tsx index 0ae86f0d..b56d49ae 100644 --- a/src/modules/chapter/utils/ChapterList.util.tsx +++ b/src/modules/chapter/utils/ChapterList.util.tsx @@ -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; const sortChapters = ( chapters: T[], @@ -87,7 +92,7 @@ const sortChapters = ( return sortedChapters; }; -type TChapterFilter = TChapterSort & ChapterReadInfo & ChapterDownloadInfo & ChapterBookmarkInfo; +type TChapterFilter = TChapterSort & ChapterReadInfo & ChapterDownloadInfo & ChapterBookmarkInfo & ChapterScanlatorInfo; export function filterAndSortChapters( chapters: Chapters[], options: ChapterListOptions, @@ -96,23 +101,25 @@ export function filterAndSortChapters( (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], ); }; diff --git a/src/modules/collection/hooks/useSelectableCollection.ts b/src/modules/collection/hooks/useSelectableCollection.ts index b066a56a..f8da80bd 100644 --- a/src/modules/collection/hooks/useSelectableCollection.ts +++ b/src/modules/collection/hooks/useSelectableCollection.ts @@ -20,6 +20,7 @@ export type SelectableCollectionReturnType void; getSelectionForKey: (key: Key) => Id[]; clearSelection: () => void; + reset: () => void; }; export const useSelectableCollection = ( @@ -122,6 +123,11 @@ export const useSelectableCollection = { + clearSelection(); + setKeyToSelectedItemIds(initialState); + }, [clearSelection, initialState]); + return { selectedItemIds, keySelectedItemIds, @@ -134,5 +140,6 @@ export const useSelectableCollection = ({ + 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 ( + + {t('category.title.set_categories')} + + + {items.length === 0 && {t('category.error.no_categories_found.label.info')}} + {items.map((item) => ( + handleSelection(getId(item), checked)} + label={getLabel(item)} + key={getId(item)} + /> + ))} + + + + + + + + + {!!items.length && ( + + )} + + + + + + ); +} diff --git a/src/modules/manga/services/MangaMetadata.ts b/src/modules/manga/services/MangaMetadata.ts index 6a441359..663ede5d 100644 --- a/src/modules/manga/services/MangaMetadata.ts +++ b/src/modules/manga/services/MangaMetadata.ts @@ -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, ): Metadata => ({ ...metadata, + excludedScanlators: JSON.stringify(metadata.excludedScanlators), }); const convertGqlMetadataToAppMetadata = ( metadata: Partial>, ): MangaMetadata => ({ ...(metadata as unknown as MangaMetadata), + excludedScanlators: jsonSaveParse(metadata.excludedScanlators as string) ?? [], }); const getMangaMetadataWithDefaultValueFallback = ( diff --git a/src/modules/metadata/Metadata.constants.ts b/src/modules/metadata/Metadata.constants.ts index c11b3da8..b705773e 100644 --- a/src/modules/metadata/Metadata.constants.ts +++ b/src/modules/metadata/Metadata.constants.ts @@ -98,6 +98,7 @@ export const APP_METADATA_KEY_TO_TYPE = { isPinned: 'boolean', lastUsedSourceId: 'string', shouldShowOnlySourcesWithResults: 'boolean', + excludedScanlators: 'string', // string[] } as const satisfies Record; export const VALID_APP_METADATA_KEYS = Object.keys(APP_METADATA_KEY_TO_TYPE);