@@ -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> = {
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface ChapterListOptions {
|
||||
reverse: boolean;
|
||||
sortBy: ChapterSortMode;
|
||||
showChapterNumber: boolean;
|
||||
excludedScanlators: string[];
|
||||
}
|
||||
|
||||
export type TChapterReader = ChapterReaderFieldsFragment;
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -216,6 +216,8 @@ export const ChapterList = ({
|
||||
options={options}
|
||||
updateOption={updateOption}
|
||||
unreadChapters={Chapters.getNonRead(chapters)}
|
||||
scanlators={Chapters.getScanlators(chapters)}
|
||||
excludeScanlators={options.excludedScanlators}
|
||||
/>
|
||||
)}
|
||||
{!!visibleChapterIds.length && (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
115
src/modules/core/components/settings/CheckboxListSetting.tsx
Normal file
115
src/modules/core/components/settings/CheckboxListSetting.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user