Feature/add manga to library category select dialog (#519)

* Move "SearchSettings" into "LibrarySettings"

* Change property "setOpen" to "onClose" for "CategorySelect"

* Optionally show category selection when adding manga to library

* Update "ignore filters" translation

* Move "ignore_filters" setting to "metadataServerSettings"
This commit is contained in:
schroda
2023-12-27 18:08:16 +01:00
committed by GitHub
parent 1345cfe624
commit e025efb9dc
12 changed files with 199 additions and 173 deletions

View File

@@ -10,7 +10,7 @@ import { StringParam, useQueryParam } from 'use-query-params';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { LibrarySortMode, NullAndUndefined, TManga } from '@/typings.ts'; import { LibrarySortMode, NullAndUndefined, TManga } from '@/typings.ts';
import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext.tsx'; import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext.tsx';
import { useSearchSettings } from '@/util/searchSettings.ts'; import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
const unreadFilter = (unread: NullAndUndefined<boolean>, { unreadCount }: TManga): boolean => { const unreadFilter = (unread: NullAndUndefined<boolean>, { unreadCount }: TManga): boolean => {
switch (unread) { switch (unread) {
@@ -105,7 +105,7 @@ export const useGetVisibleLibraryMangas = (mangas: TManga[]) => {
const [query] = useQueryParam('query', StringParam); const [query] = useQueryParam('query', StringParam);
const { options } = useLibraryOptionsContext(); const { options } = useLibraryOptionsContext();
const { unread, downloaded } = options; const { unread, downloaded } = options;
const { settings } = useSearchSettings(); const { settings } = useMetadataServerSettings();
const filteredMangas = useMemo( const filteredMangas = useMemo(
() => filterManga(mangas, query, unread, downloaded, settings.ignoreFilters), () => filterManga(mangas, query, unread, downloaded, settings.ignoreFilters),

View File

@@ -115,8 +115,8 @@ export const MangaActionMenu = ({
{isCategorySelectOpen && ( {isCategorySelectOpen && (
<CategorySelect <CategorySelect
open={isCategorySelectOpen} open={isCategorySelectOpen}
setOpen={(open) => { onClose={() => {
setIsCategorySelectOpen(open); setIsCategorySelectOpen(false);
bindMenuProps.onClose(); bindMenuProps.onClose();
}} }}
mangaId={manga.id} mangaId={manga.id}

View File

@@ -10,13 +10,15 @@ import FavoriteIcon from '@mui/icons-material/Favorite';
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder'; import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
import PublicIcon from '@mui/icons-material/Public'; import PublicIcon from '@mui/icons-material/Public';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import React, { useEffect, useMemo } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { t as translate } from 'i18next'; import { t as translate } from 'i18next';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import { ISource, TManga } from '@/typings'; import { ISource, TManga } from '@/typings';
import { requestManager } from '@/lib/requests/RequestManager.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts';
import { makeToast } from '@/components/util/Toast'; import { makeToast } from '@/components/util/Toast';
import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
import { CategorySelect } from '@/components/navbar/action/CategorySelect.tsx';
const DetailsWrapper = styled('div')(({ theme }) => ({ const DetailsWrapper = styled('div')(({ theme }) => ({
width: '100%', width: '100%',
@@ -167,12 +169,13 @@ function getValueOrUnknown(val?: string | null) {
export const MangaDetails: React.FC<IProps> = ({ manga }) => { export const MangaDetails: React.FC<IProps> = ({ manga }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: categoriesData, loading: areCategoriesLoading } = requestManager.useGetCategories();
const categories = categoriesData?.categories.nodes ?? []; const {
const defaultCategoryIds = categories settings: { showAddToLibraryCategorySelectDialog },
.filter((category) => category.default && category.id !== 0) loading: areSettingsLoading,
.map((category) => category.id); } = useMetadataServerSettings();
const [updateMangaCategories] = requestManager.useUpdateMangaCategories();
const [isCategorySelectOpen, setIsCategorySelectOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (!manga.source) { if (!manga.source) {
@@ -181,18 +184,23 @@ export const MangaDetails: React.FC<IProps> = ({ manga }) => {
}, [manga.source]); }, [manga.source]);
const addToLibrary = () => { const addToLibrary = () => {
Promise.all([ requestManager
requestManager.updateManga(manga.id, { inLibrary: true }).response, .updateManga(manga.id, { inLibrary: true })
updateMangaCategories({ .response.then(() => makeToast(t('library.info.label.added_to_library'), 'success'))
variables: { input: { id: manga.id, patch: { addToCategories: defaultCategoryIds } } },
}),
])
.then(() => makeToast(t('library.info.label.added_to_library'), 'success'))
.catch(() => { .catch(() => {
makeToast(t('library.error.label.add_to_library'), 'error'); makeToast(t('library.error.label.add_to_library'), 'error');
}); });
}; };
const handleAddToLibraryClick = () => {
if (!showAddToLibraryCategorySelectDialog) {
addToLibrary();
return;
}
setIsCategorySelectOpen(true);
};
const removeFromLibrary = () => { const removeFromLibrary = () => {
Promise.all([requestManager.updateManga(manga.id, { inLibrary: false }).response]) Promise.all([requestManager.updateManga(manga.id, { inLibrary: false }).response])
.then(() => makeToast(t('library.info.label.removed_from_library'), 'success')) .then(() => makeToast(t('library.info.label.removed_from_library'), 'success'))
@@ -202,6 +210,7 @@ export const MangaDetails: React.FC<IProps> = ({ manga }) => {
}; };
return ( return (
<>
<DetailsWrapper> <DetailsWrapper>
<TopContentWrapper> <TopContentWrapper>
<ThumbnailMetadataWrapper> <ThumbnailMetadataWrapper>
@@ -227,9 +236,9 @@ export const MangaDetails: React.FC<IProps> = ({ manga }) => {
<MangaButtonsContainer inLibrary={manga.inLibrary}> <MangaButtonsContainer inLibrary={manga.inLibrary}>
<div> <div>
<Button <Button
disabled={areCategoriesLoading} disabled={areSettingsLoading}
startIcon={manga.inLibrary ? <FavoriteIcon /> : <FavoriteBorderIcon />} startIcon={manga.inLibrary ? <FavoriteIcon /> : <FavoriteBorderIcon />}
onClick={manga.inLibrary ? removeFromLibrary : addToLibrary} onClick={manga.inLibrary ? removeFromLibrary : handleAddToLibraryClick}
size="large" size="large"
> >
{manga.inLibrary ? t('manga.button.in_library') : t('manga.button.add_to_library')} {manga.inLibrary ? t('manga.button.in_library') : t('manga.button.add_to_library')}
@@ -250,5 +259,20 @@ export const MangaDetails: React.FC<IProps> = ({ manga }) => {
</Genres> </Genres>
</BottomContentWrapper> </BottomContentWrapper>
</DetailsWrapper> </DetailsWrapper>
{isCategorySelectOpen && (
<CategorySelect
open={isCategorySelectOpen}
onClose={(didUpdateCategories) => {
setIsCategorySelectOpen(false);
if (didUpdateCategories) {
addToLibrary();
}
}}
mangaId={manga.id}
addToLibrary
/>
)}
</>
); );
}; };

View File

@@ -119,7 +119,7 @@ export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
</> </>
)} )}
<CategorySelect open={editCategories} setOpen={setEditCategories} mangaId={manga.id} /> <CategorySelect open={editCategories} onClose={() => setEditCategories(false)} mangaId={manga.id} />
</> </>
); );
}; };

View File

@@ -93,8 +93,8 @@ export const MangasSelectionFABActionItems = ({
{isCategorySelectOpen && ( {isCategorySelectOpen && (
<CategorySelect <CategorySelect
open={isCategorySelectOpen} open={isCategorySelectOpen}
setOpen={(open) => { onClose={() => {
setIsCategorySelectOpen(open); setIsCategorySelectOpen(false);
handleClose(true); handleClose(true);
}} }}
mangaIds={Mangas.getIds(selectedMangas)} mangaIds={Mangas.getIds(selectedMangas)}

View File

@@ -18,14 +18,16 @@ import { requestManager } from '@/lib/requests/RequestManager.ts';
import { Mangas } from '@/lib/data/Mangas.ts'; import { Mangas } from '@/lib/data/Mangas.ts';
import { useSelectableCollection } from '@/components/collection/useSelectableCollection.ts'; import { useSelectableCollection } from '@/components/collection/useSelectableCollection.ts';
import { ThreeStateCheckboxInput } from '@/components/atoms/ThreeStateCheckboxInput.tsx'; import { ThreeStateCheckboxInput } from '@/components/atoms/ThreeStateCheckboxInput.tsx';
import { TCategory } from '@/typings.ts';
type BaseProps = { type BaseProps = {
open: boolean; open: boolean;
setOpen: (value: boolean) => void; onClose: (didUpdateCategories: boolean) => void;
}; };
type SingleMangaModeProps = { type SingleMangaModeProps = {
mangaId: number; mangaId: number;
addToLibrary?: boolean;
}; };
type MultiMangaModeProps = { type MultiMangaModeProps = {
@@ -69,10 +71,13 @@ const getCategoryCheckedState = (
return undefined; return undefined;
}; };
const getDefaultCategoryIds = (categories: TCategory[]) =>
categories.filter(({ default: isDefault }) => isDefault).map(({ id }) => id);
export function CategorySelect(props: Props) { export function CategorySelect(props: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { open, setOpen, mangaId, mangaIds: passedMangaIds } = props; const { open, onClose, mangaId, mangaIds: passedMangaIds, addToLibrary = false } = props;
const isSingleSelectionMode = mangaId !== undefined; const isSingleSelectionMode = mangaId !== undefined;
const mangaIds = passedMangaIds ?? [mangaId]; const mangaIds = passedMangaIds ?? [mangaId];
@@ -89,19 +94,24 @@ export function CategorySelect(props: Props) {
return cats; return cats;
}, [categoriesData]); }, [categoriesData]);
const defaultCategoryIds = useMemo(
() => (addToLibrary ? getDefaultCategoryIds(allCategories) : []),
[allCategories],
);
const { handleSelection, setSelectionForKey, getSelectionForKey } = useSelectableCollection< const { handleSelection, setSelectionForKey, getSelectionForKey } = useSelectableCollection<
number, number,
'categoriesToAdd' | 'categoriesToRemove' 'categoriesToAdd' | 'categoriesToRemove'
>(allCategories.length, { >(allCategories.length, {
currentKey: 'categoriesToAdd', currentKey: 'categoriesToAdd',
initialState: { initialState: {
categoriesToAdd: mangaCategoryIds, categoriesToAdd: [...mangaCategoryIds, ...defaultCategoryIds],
categoriesToRemove: [], categoriesToRemove: [],
}, },
}); });
useEffect(() => { useEffect(() => {
setSelectionForKey('categoriesToAdd', mangaCategoryIds); setSelectionForKey('categoriesToAdd', [...mangaCategoryIds, ...defaultCategoryIds]);
setSelectionForKey('categoriesToRemove', []); setSelectionForKey('categoriesToRemove', []);
}, [mangaCategoryIds]); }, [mangaCategoryIds]);
@@ -111,11 +121,11 @@ export function CategorySelect(props: Props) {
const handleCancel = () => { const handleCancel = () => {
setSelectionForKey('categoriesToAdd', mangaCategoryIds); setSelectionForKey('categoriesToAdd', mangaCategoryIds);
setSelectionForKey('categoriesToRemove', []); setSelectionForKey('categoriesToRemove', []);
setOpen(false); onClose(false);
}; };
const handleOk = () => { const handleOk = () => {
setOpen(false); onClose(true);
const addToCategories = isSingleSelectionMode const addToCategories = isSingleSelectionMode
? categoriesToAdd.filter((categoryId) => !mangaCategoryIds.includes(categoryId)) ? categoriesToAdd.filter((categoryId) => !mangaCategoryIds.includes(categoryId))

View File

@@ -314,6 +314,7 @@
"discord": "Discord", "discord": "Discord",
"display": "Display", "display": "Display",
"filter": "Filter", "filter": "Filter",
"general": "General",
"github": "GitHub", "github": "GitHub",
"links": "Links", "links": "Links",
"loading": "Loading…", "loading": "Loading…",
@@ -402,6 +403,24 @@
} }
}, },
"settings": { "settings": {
"general": {
"add_to_library": {
"category_selection": {
"label": {
"description": "Show the category selection dialog when adding a manga to the library",
"title": "Category selection dialog"
}
}
},
"search": {
"ignore_filters": {
"label": {
"description": "Search results will include manga that do not match the current filters",
"title": "Ignore filters when searching"
}
}
}
},
"global_update": { "global_update": {
"auto_update": { "auto_update": {
"interval": { "interval": {
@@ -447,6 +466,18 @@
}, },
"manga": { "manga": {
"action": { "action": {
"category": {
"button": {
"selected": "Change categories of selected"
},
"label": {
"action": "Change categories",
"error_one": "Could not change the categories of the manga",
"error_other": "Could not change the categories of the manga",
"success_one": "Changed categories of manga",
"success_other": "Changed categories of {{count}} manga"
}
},
"library": { "library": {
"remove": { "remove": {
"button": { "button": {
@@ -460,18 +491,6 @@
"success_other": "Removed {{count}} manga from the library" "success_other": "Removed {{count}} manga from the library"
} }
} }
},
"category": {
"button": {
"selected": "Change categories of selected"
},
"label": {
"action": "Change categories",
"error_one": "Could not change the categories of the manga",
"error_other": "Could not change the categories of the manga",
"success_one": "Changed categories of manga",
"success_other": "Changed categories of {{count}} manga"
}
} }
}, },
"button": { "button": {
@@ -558,9 +577,6 @@
"source_search_failed": "Could not search source" "source_search_failed": "Could not search source"
} }
}, },
"label": {
"ignore_filters": "Ignore filters when searching"
},
"title": { "title": {
"global_search": "Global Search", "global_search": "Global Search",
"search": "Search" "search": "Search"

View File

@@ -8,9 +8,14 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useContext, useEffect } from 'react'; import { useContext, useEffect } from 'react';
import { List, ListItem, ListItemText, Switch } from '@mui/material';
import ListSubheader from '@mui/material/ListSubheader';
import { NavBarContext, useSetDefaultBackTo } from '@/components/context/NavbarContext.tsx'; import { NavBarContext, useSetDefaultBackTo } from '@/components/context/NavbarContext.tsx';
import { GlobalUpdateSettings } from '@/components/settings/globalUpdate/GlobalUpdateSettings.tsx'; import { GlobalUpdateSettings } from '@/components/settings/globalUpdate/GlobalUpdateSettings.tsx';
import { SearchSettings } from '@/screens/settings/SearchSettings.tsx'; import { MetadataServerSettingKeys, MetadataServerSettings } from '@/typings.ts';
import { convertToGqlMeta, requestUpdateServerMetadata } from '@/util/metadata.ts';
import { makeToast } from '@/components/util/Toast.tsx';
import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
export function LibrarySettings() { export function LibrarySettings() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -23,10 +28,50 @@ export function LibrarySettings() {
useSetDefaultBackTo('settings'); useSetDefaultBackTo('settings');
const { metadata, settings } = useMetadataServerSettings();
const setSettingValue = <Setting extends MetadataServerSettingKeys>(
setting: Setting,
value: MetadataServerSettings[Setting],
) => {
requestUpdateServerMetadata(convertToGqlMeta(metadata)! ?? {}, [[setting, value]]).catch(() =>
makeToast(t('search.error.label.failed_to_save_settings'), 'warning'),
);
};
return ( return (
<> <List>
<SearchSettings /> <List
subheader={
<ListSubheader component="div" id="library-general-settings">
{t('global.label.general')}
</ListSubheader>
}
>
<ListItem>
<ListItemText
primary={t('library.settings.general.search.ignore_filters.label.title')}
secondary={t('library.settings.general.search.ignore_filters.label.description')}
/>
<Switch
edge="end"
checked={settings.ignoreFilters}
onChange={(e) => setSettingValue('ignoreFilters', e.target.checked)}
/>
</ListItem>
<ListItem>
<ListItemText
primary={t('library.settings.general.add_to_library.category_selection.label.title')}
secondary={t('library.settings.general.add_to_library.category_selection.label.description')}
/>
<Switch
edge="end"
checked={settings.showAddToLibraryCategorySelectDialog}
onChange={(e) => setSettingValue('showAddToLibraryCategorySelectDialog', e.target.checked)}
/>
</ListItem>
</List>
<GlobalUpdateSettings /> <GlobalUpdateSettings />
</> </List>
); );
} }

View File

@@ -1,47 +0,0 @@
/*
* 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 { List, ListItem, ListItemText, Switch } from '@mui/material';
import { useTranslation } from 'react-i18next';
import ListSubheader from '@mui/material/ListSubheader';
import { SearchMetadataKeys } from '@/typings';
import { convertToGqlMeta, requestUpdateServerMetadata } from '@/util/metadata';
import { useSearchSettings } from '@/util/searchSettings';
import { makeToast } from '@/components/util/Toast';
import { useSetDefaultBackTo } from '@/components/context/NavbarContext';
export function SearchSettings() {
const { t } = useTranslation();
const { metadata, settings } = useSearchSettings();
useSetDefaultBackTo('settings');
const setSettingValue = (key: SearchMetadataKeys, value: boolean) => {
requestUpdateServerMetadata(convertToGqlMeta(metadata)! ?? {}, [[key, value]]).catch(() =>
makeToast(t('search.error.label.failed_to_save_settings'), 'warning'),
);
};
return (
<List
subheader={
<ListSubheader component="div" id="library-search-filter">
{t('search.title.search')}
</ListSubheader>
}
>
<ListItem>
<ListItemText primary={t('search.label.ignore_filters')} />
<Switch
edge="end"
checked={settings.ignoreFilters}
onChange={(e) => setSettingValue('ignoreFilters', e.target.checked)}
/>
</ListItem>
</List>
);
}

View File

@@ -213,9 +213,14 @@ export enum ChapterOffset {
} }
export type MetadataServerSettings = { export type MetadataServerSettings = {
// downloads
deleteChaptersManuallyMarkedRead: boolean; deleteChaptersManuallyMarkedRead: boolean;
deleteChaptersWhileReading: number; deleteChaptersWhileReading: number;
deleteChaptersWithBookmark: boolean; deleteChaptersWithBookmark: boolean;
// library
showAddToLibraryCategorySelectDialog: boolean;
ignoreFilters: boolean;
}; };
export interface ISearchSettings { export interface ISearchSettings {

View File

@@ -11,9 +11,14 @@ import { requestManager } from '@/lib/requests/RequestManager.ts';
import { convertFromGqlMeta, getMetadataFrom } from '@/util/metadata'; import { convertFromGqlMeta, getMetadataFrom } from '@/util/metadata';
export const getDefaultSettings = (): MetadataServerSettings => ({ export const getDefaultSettings = (): MetadataServerSettings => ({
// downloads
deleteChaptersManuallyMarkedRead: false, deleteChaptersManuallyMarkedRead: false,
deleteChaptersWhileReading: 0, deleteChaptersWhileReading: 0,
deleteChaptersWithBookmark: false, deleteChaptersWithBookmark: false,
// library
showAddToLibraryCategorySelectDialog: true,
ignoreFilters: false,
}); });
const getMetadataServerSettingsWithDefaultFallback = ( const getMetadataServerSettingsWithDefaultFallback = (

View File

@@ -1,32 +0,0 @@
/*
* 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 { Metadata, ISearchSettings } from '@/typings';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { convertFromGqlMeta, getMetadataFrom } from '@/util/metadata';
export const getDefaultSettings = (): ISearchSettings => ({
ignoreFilters: false,
});
const getSearchSettingsWithDefaultValueFallback = (
meta?: Metadata,
defaultSettings: ISearchSettings = getDefaultSettings(),
applyMetadataMigration: boolean = true,
): ISearchSettings => getMetadataFrom({ meta }, defaultSettings, applyMetadataMigration);
export const useSearchSettings = (): {
metadata?: Metadata;
settings: ISearchSettings;
loading: boolean;
} => {
const { data, loading } = requestManager.useGetGlobalMeta();
const metadata = convertFromGqlMeta(data?.metas.nodes);
const settings = getSearchSettingsWithDefaultValueFallback(metadata);
return { metadata, settings, loading };
};