Feature/library global update exclude manga with state (#281)
* Extract global update settings * Add option to exclude mangas with specific state from global update
This commit is contained in:
17
src/components/globalUpdate/CheckboxContainer.ts
Normal file
17
src/components/globalUpdate/CheckboxContainer.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/*
|
||||||
|
* 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 { styled } from '@mui/material';
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
|
export const CheckboxContainer = styled('div')({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
maxHeight: '170px',
|
||||||
|
overflow: 'auto',
|
||||||
|
});
|
||||||
31
src/components/globalUpdate/GlobalUpdateSettings.tsx
Normal file
31
src/components/globalUpdate/GlobalUpdateSettings.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/*
|
||||||
|
* 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 from '@mui/material/List';
|
||||||
|
import ListSubheader from '@mui/material/ListSubheader';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { GlobalUpdateSettingsCategories } from '@/components/globalUpdate/GlobalUpdateSettingsCategories.tsx';
|
||||||
|
import { GlobalUpdateSettingsEntries } from '@/components/globalUpdate/GlobalUpdateSettingsEntries.tsx';
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
|
export const GlobalUpdateSettings = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
subheader={
|
||||||
|
<ListSubheader component="div" id="global-update-settings">
|
||||||
|
{t('library.settings.global_update.title')}
|
||||||
|
</ListSubheader>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<GlobalUpdateSettingsEntries />
|
||||||
|
<GlobalUpdateSettingsCategories />
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
};
|
||||||
217
src/components/globalUpdate/GlobalUpdateSettingsCategories.tsx
Normal file
217
src/components/globalUpdate/GlobalUpdateSettingsCategories.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/*
|
||||||
|
* 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 { useTranslation } from 'react-i18next';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import DialogContentText from '@mui/material/DialogContentText';
|
||||||
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import { t as translate } from 'i18next';
|
||||||
|
import ThreeStateCheckboxInput from '@/components/atoms/ThreeStateCheckboxInput.tsx';
|
||||||
|
import makeToast from '@/components/util/Toast.tsx';
|
||||||
|
import { IncludeInUpdate } from '@/lib/graphql/generated/graphql.ts';
|
||||||
|
import { TCategory } from '@/typings.ts';
|
||||||
|
import requestManager from '@/lib/requests/RequestManager.ts';
|
||||||
|
import { CheckboxContainer } from '@/components/globalUpdate/CheckboxContainer.ts';
|
||||||
|
|
||||||
|
const booleanToIncludeInStatus = (status: boolean | null | undefined): IncludeInUpdate => {
|
||||||
|
switch (status) {
|
||||||
|
case false:
|
||||||
|
return IncludeInUpdate.Exclude;
|
||||||
|
case true:
|
||||||
|
return IncludeInUpdate.Include;
|
||||||
|
case null:
|
||||||
|
case undefined:
|
||||||
|
return IncludeInUpdate.Unset;
|
||||||
|
default:
|
||||||
|
throw new Error(`booleanToIncludeInStatus: unexpected IncludeInUpdate status "${status}"`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const includeInUpdateStatusToBoolean = (status: IncludeInUpdate): boolean | null => {
|
||||||
|
switch (status) {
|
||||||
|
case IncludeInUpdate.Exclude:
|
||||||
|
return false;
|
||||||
|
case IncludeInUpdate.Include:
|
||||||
|
return true;
|
||||||
|
case IncludeInUpdate.Unset:
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
throw new Error(`includeInUpdateStatusToBoolean: unexpected IncludeInUpdate status "${status}"`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryUpdateInfo = (
|
||||||
|
categories: TCategory[],
|
||||||
|
areIncluded: boolean,
|
||||||
|
unsetCategories: number,
|
||||||
|
allCategories: number,
|
||||||
|
error: any,
|
||||||
|
) => {
|
||||||
|
if (error) {
|
||||||
|
return translate('global.error.label.failed_to_load_data');
|
||||||
|
}
|
||||||
|
if (allCategories === -1) {
|
||||||
|
return translate('global.label.loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
const noSpecificallyIncludedCategories = areIncluded && !categories.length && unsetCategories;
|
||||||
|
const includesAllCategories = categories.length === allCategories;
|
||||||
|
if (noSpecificallyIncludedCategories || includesAllCategories) {
|
||||||
|
return translate('extension.language.all');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!categories.length) {
|
||||||
|
return translate('global.label.none');
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories.map((category) => category.name).join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
|
export const GlobalUpdateSettingsCategories = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data, error: requestError } = requestManager.useGetCategories();
|
||||||
|
const categories = data?.categories.nodes;
|
||||||
|
const [dialogCategories, setDialogCategories] = useState<TCategory[]>(categories ?? []);
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!categories) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDialogCategories(categories);
|
||||||
|
}, [categories]);
|
||||||
|
|
||||||
|
const unsetCategories: TCategory[] =
|
||||||
|
categories?.filter((category) => category.includeInUpdate === IncludeInUpdate.Unset) ?? [];
|
||||||
|
const excludedCategories: TCategory[] =
|
||||||
|
categories?.filter((category) => category.includeInUpdate === IncludeInUpdate.Exclude) ?? [];
|
||||||
|
const includedCategories: TCategory[] =
|
||||||
|
categories?.filter((category) => category.includeInUpdate === IncludeInUpdate.Include) ?? [];
|
||||||
|
const excludedCategoriesText = getCategoryUpdateInfo(
|
||||||
|
excludedCategories,
|
||||||
|
false,
|
||||||
|
unsetCategories.length,
|
||||||
|
categories?.length ?? -1,
|
||||||
|
requestError,
|
||||||
|
);
|
||||||
|
const includedCategoriesText = getCategoryUpdateInfo(
|
||||||
|
includedCategories,
|
||||||
|
true,
|
||||||
|
unsetCategories.length,
|
||||||
|
categories?.length ?? -1,
|
||||||
|
requestError,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCategory = (category: TCategory) =>
|
||||||
|
requestManager.updateCategory(category.id, { includeInUpdate: category.includeInUpdate }).response;
|
||||||
|
|
||||||
|
const updateCategories = async () => {
|
||||||
|
const categoriesToUpdate = dialogCategories.filter((category) => {
|
||||||
|
const currentCategory = categories?.find((currCategory) => currCategory.id === category.id);
|
||||||
|
|
||||||
|
if (!currentCategory) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentCategory.includeInUpdate !== category.includeInUpdate;
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(categoriesToUpdate.map((category) => updateCategory(category)));
|
||||||
|
// TODO - update cache immediately
|
||||||
|
// mutate(categoriesEndpoint, [...dialogCategories], { revalidate: false });
|
||||||
|
} catch (error) {
|
||||||
|
makeToast(t('global.error.label.failed_to_save_changes'), 'error');
|
||||||
|
// mutate(categoriesEndpoint, [...categories]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setDialogCategories(categories ?? []);
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ListItemButton onClick={() => setIsDialogOpen(true)}>
|
||||||
|
<ListItemText
|
||||||
|
primary={t('category.title.categories')}
|
||||||
|
secondary={
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
{t('library.settings.global_update.categories.label.include', {
|
||||||
|
includedCategoriesText,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t('library.settings.global_update.categories.label.exclude', {
|
||||||
|
excludedCategoriesText,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
secondaryTypographyProps={{ style: { display: 'flex', flexDirection: 'column' } }}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
|
||||||
|
<Dialog open={isDialogOpen} onClose={closeDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogTitle sx={{ paddingLeft: 0 }}>{t('category.title.categories')}</DialogTitle>
|
||||||
|
<DialogContentText sx={{ paddingBottom: '10px' }}>
|
||||||
|
{t('library.settings.global_update.categories.label.info')}
|
||||||
|
</DialogContentText>
|
||||||
|
<CheckboxContainer>
|
||||||
|
{dialogCategories.map((category) => (
|
||||||
|
<ThreeStateCheckboxInput
|
||||||
|
key={category.id}
|
||||||
|
label={category.name}
|
||||||
|
checked={includeInUpdateStatusToBoolean(category.includeInUpdate)}
|
||||||
|
onChange={(checked) => {
|
||||||
|
const newIncludeState = booleanToIncludeInStatus(checked);
|
||||||
|
|
||||||
|
const categoryIndex = dialogCategories.findIndex(
|
||||||
|
(category_) => category_ === category,
|
||||||
|
);
|
||||||
|
const updatedDialogCategories: TCategory[] = [
|
||||||
|
...dialogCategories.slice(0, categoryIndex),
|
||||||
|
{
|
||||||
|
...category,
|
||||||
|
includeInUpdate: newIncludeState,
|
||||||
|
},
|
||||||
|
...dialogCategories.slice(categoryIndex + 1, dialogCategories.length),
|
||||||
|
];
|
||||||
|
|
||||||
|
setDialogCategories(updatedDialogCategories);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CheckboxContainer>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={closeDialog} color="primary">
|
||||||
|
{t('global.button.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={updateCategories} color="primary">
|
||||||
|
{t('global.button.ok')}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
164
src/components/globalUpdate/GlobalUpdateSettingsEntries.tsx
Normal file
164
src/components/globalUpdate/GlobalUpdateSettingsEntries.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
/*
|
||||||
|
* 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 { t as translate } from 'i18next';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material';
|
||||||
|
import { GetServerSettingsQuery } from '@/lib/graphql/generated/graphql.ts';
|
||||||
|
import { TranslationKey } from '@/typings.ts';
|
||||||
|
import requestManager from '@/lib/requests/RequestManager.ts';
|
||||||
|
import makeToast from '@/components/util/Toast.tsx';
|
||||||
|
import { CheckboxContainer } from '@/components/globalUpdate/CheckboxContainer';
|
||||||
|
import CheckboxInput from '@/components/atoms/CheckboxInput';
|
||||||
|
|
||||||
|
type GlobalUpdateSkipEntriesSettings = Pick<
|
||||||
|
GetServerSettingsQuery['settings'],
|
||||||
|
'excludeUnreadChapters' | 'excludeNotStarted' | 'excludeCompleted'
|
||||||
|
>;
|
||||||
|
|
||||||
|
const settingToTextMap: { [setting in keyof GlobalUpdateSkipEntriesSettings]: TranslationKey } = {
|
||||||
|
excludeUnreadChapters: 'library.settings.global_update.entries.label.unread_chapters',
|
||||||
|
excludeNotStarted: 'library.settings.global_update.entries.label.not_started',
|
||||||
|
excludeCompleted: 'library.settings.global_update.entries.label.completed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSkipMangasText = (settings: GlobalUpdateSkipEntriesSettings | undefined, isLoading: boolean, error: any) => {
|
||||||
|
if (error) {
|
||||||
|
return translate('global.error.label.failed_to_load_data');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings || isLoading) {
|
||||||
|
return translate('global.label.loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipSettings: string[] = [];
|
||||||
|
|
||||||
|
if (settings.excludeUnreadChapters) {
|
||||||
|
skipSettings.push(translate(settingToTextMap.excludeUnreadChapters) as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.excludeNotStarted) {
|
||||||
|
skipSettings.push(translate(settingToTextMap.excludeNotStarted) as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.excludeCompleted) {
|
||||||
|
skipSettings.push(translate(settingToTextMap.excludeCompleted) as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNothingExcluded = !skipSettings.length;
|
||||||
|
if (isNothingExcluded) {
|
||||||
|
skipSettings.push(translate('global.label.none'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return skipSettings.join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractSkipEntriesSettings = (
|
||||||
|
serverSettings: GetServerSettingsQuery['settings'],
|
||||||
|
): GlobalUpdateSkipEntriesSettings => ({
|
||||||
|
excludeCompleted: serverSettings.excludeCompleted,
|
||||||
|
excludeNotStarted: serverSettings.excludeNotStarted,
|
||||||
|
excludeUnreadChapters: serverSettings.excludeUnreadChapters,
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
|
export const GlobalUpdateSettingsEntries = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, loading, error: requestError } = requestManager.useGetServerSettings();
|
||||||
|
const globalUpdateSettings = data ? extractSkipEntriesSettings(data.settings) : undefined;
|
||||||
|
const [mutateSettings] = requestManager.useUpdateServerSettings();
|
||||||
|
|
||||||
|
const [dialogSettings, setDialogSettings] = useState<GlobalUpdateSkipEntriesSettings>(
|
||||||
|
globalUpdateSettings ?? ({} as GlobalUpdateSkipEntriesSettings),
|
||||||
|
);
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const skipEntriesText = getSkipMangasText(globalUpdateSettings, loading, requestError);
|
||||||
|
|
||||||
|
const updateSettings = async () => {
|
||||||
|
const didSettingsChange =
|
||||||
|
globalUpdateSettings?.excludeCompleted !== dialogSettings.excludeCompleted ||
|
||||||
|
globalUpdateSettings.excludeNotStarted !== dialogSettings.excludeNotStarted ||
|
||||||
|
globalUpdateSettings.excludeUnreadChapters !== dialogSettings.excludeUnreadChapters;
|
||||||
|
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
|
||||||
|
if (!didSettingsChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mutateSettings({ variables: { input: { settings: dialogSettings } } });
|
||||||
|
} catch (error) {
|
||||||
|
makeToast(t('global.error.label.failed_to_save_changes'), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setDialogSettings(globalUpdateSettings ?? ({} as GlobalUpdateSkipEntriesSettings));
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!globalUpdateSettings) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDialogSettings(globalUpdateSettings);
|
||||||
|
}, [
|
||||||
|
globalUpdateSettings?.excludeCompleted,
|
||||||
|
globalUpdateSettings?.excludeNotStarted,
|
||||||
|
globalUpdateSettings?.excludeUnreadChapters,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ListItemButton onClick={() => setIsDialogOpen(true)}>
|
||||||
|
<ListItemText
|
||||||
|
primary={t('library.settings.global_update.entries.title')}
|
||||||
|
secondary={skipEntriesText}
|
||||||
|
onClick={() => setIsDialogOpen(true)}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
|
||||||
|
<Dialog open={isDialogOpen} onClose={closeDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogTitle sx={{ paddingLeft: 0 }}>
|
||||||
|
{t('library.settings.global_update.entries.title')}
|
||||||
|
</DialogTitle>
|
||||||
|
<CheckboxContainer>
|
||||||
|
{Object.entries(dialogSettings).map(([setting, value]) => (
|
||||||
|
<CheckboxInput
|
||||||
|
key={setting}
|
||||||
|
label={t(settingToTextMap[setting as keyof GlobalUpdateSkipEntriesSettings])}
|
||||||
|
checked={value}
|
||||||
|
onChange={(_, checked) => {
|
||||||
|
setDialogSettings({
|
||||||
|
...dialogSettings,
|
||||||
|
[setting]: checked,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CheckboxContainer>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={closeDialog} color="primary">
|
||||||
|
{t('global.button.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={updateSettings} color="primary">
|
||||||
|
{t('global.button.ok')}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -313,6 +313,14 @@
|
|||||||
"info": "Entries in excluded categories will not be updated even if they are also in included categories"
|
"info": "Entries in excluded categories will not be updated even if they are also in included categories"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"entries": {
|
||||||
|
"title": "Skip updating entries",
|
||||||
|
"label": {
|
||||||
|
"completed": "With \"Completed\" status",
|
||||||
|
"not_started": "That haven't been started",
|
||||||
|
"unread_chapters": "With unread chapter(s)"
|
||||||
|
}
|
||||||
|
},
|
||||||
"title": "Global update"
|
"title": "Global update"
|
||||||
},
|
},
|
||||||
"title": "Library Settings"
|
"title": "Library Settings"
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ import {
|
|||||||
GetMangaQueryVariables,
|
GetMangaQueryVariables,
|
||||||
GetMangasQuery,
|
GetMangasQuery,
|
||||||
GetMangasQueryVariables,
|
GetMangasQueryVariables,
|
||||||
|
GetServerSettingsQuery,
|
||||||
GetSourceMangasFetchMutation,
|
GetSourceMangasFetchMutation,
|
||||||
GetSourceMangasFetchMutationVariables,
|
GetSourceMangasFetchMutationVariables,
|
||||||
GetSourceQuery,
|
GetSourceQuery,
|
||||||
@@ -137,6 +138,8 @@ import {
|
|||||||
UpdateMangaPatchInput,
|
UpdateMangaPatchInput,
|
||||||
UpdaterSubscription,
|
UpdaterSubscription,
|
||||||
UpdaterSubscriptionVariables,
|
UpdaterSubscriptionVariables,
|
||||||
|
UpdateServerSettingsMutation,
|
||||||
|
UpdateServerSettingsMutationVariables,
|
||||||
UpdateSourcePreferencesMutation,
|
UpdateSourcePreferencesMutation,
|
||||||
UpdateSourcePreferencesMutationVariables,
|
UpdateSourcePreferencesMutationVariables,
|
||||||
ValidateBackupQuery,
|
ValidateBackupQuery,
|
||||||
@@ -199,6 +202,8 @@ import { RESTORE_BACKUP } from '@/lib/graphql/mutations/BackupMutation.ts';
|
|||||||
import { VALIDATE_BACKUP } from '@/lib/graphql/queries/BackupQuery.ts';
|
import { VALIDATE_BACKUP } from '@/lib/graphql/queries/BackupQuery.ts';
|
||||||
import { DOWNLOAD_STATUS_SUBSCRIPTION } from '@/lib/graphql/subscriptions/DownloaderSubscription.ts';
|
import { DOWNLOAD_STATUS_SUBSCRIPTION } from '@/lib/graphql/subscriptions/DownloaderSubscription.ts';
|
||||||
import { UPDATER_SUBSCRIPTION } from '@/lib/graphql/subscriptions/UpdaterSubscription.ts';
|
import { UPDATER_SUBSCRIPTION } from '@/lib/graphql/subscriptions/UpdaterSubscription.ts';
|
||||||
|
import { GET_SERVER_SETTINGS } from '@/lib/graphql/queries/SettingsQuery.ts';
|
||||||
|
import { UPDATE_SERVER_SETTINGS } from '@/lib/graphql/mutations/SettingsMutation.ts';
|
||||||
|
|
||||||
enum GQLMethod {
|
enum GQLMethod {
|
||||||
QUERY = 'QUERY',
|
QUERY = 'QUERY',
|
||||||
@@ -1776,6 +1781,18 @@ export class RequestManager {
|
|||||||
): SubscriptionResult<UpdaterSubscription, UpdaterSubscriptionVariables> {
|
): SubscriptionResult<UpdaterSubscription, UpdaterSubscriptionVariables> {
|
||||||
return this.doRequest(GQLMethod.USE_SUBSCRIPTION, UPDATER_SUBSCRIPTION, {}, options);
|
return this.doRequest(GQLMethod.USE_SUBSCRIPTION, UPDATER_SUBSCRIPTION, {}, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public useGetServerSettings(
|
||||||
|
options?: QueryHookOptions<GetServerSettingsQuery, GetSourcesQueryVariables>,
|
||||||
|
): AbortableApolloUseQueryResponse<GetServerSettingsQuery, GetSourcesQueryVariables> {
|
||||||
|
return this.doRequest(GQLMethod.USE_QUERY, GET_SERVER_SETTINGS, undefined, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public useUpdateServerSettings(
|
||||||
|
options?: MutationHookOptions<UpdateServerSettingsMutation, UpdateServerSettingsMutationVariables>,
|
||||||
|
): AbortableApolloUseMutationResponse<UpdateServerSettingsMutation, UpdateServerSettingsMutationVariables> {
|
||||||
|
return this.doRequest(GQLMethod.USE_MUTATION, UPDATE_SERVER_SETTINGS, undefined, options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestManager = new RequestManager();
|
const requestManager = new RequestManager();
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const typePolicies: StrictTypedTypePolicies = {
|
|||||||
GlobalMetaType: { keyFields: ['key'] },
|
GlobalMetaType: { keyFields: ['key'] },
|
||||||
ExtensionType: { keyFields: ['apkName'] },
|
ExtensionType: { keyFields: ['apkName'] },
|
||||||
AboutPayload: { keyFields: [] },
|
AboutPayload: { keyFields: [] },
|
||||||
|
SettingsType: { keyFields: [] },
|
||||||
Query: {
|
Query: {
|
||||||
fields: {
|
fields: {
|
||||||
chapters: {
|
chapters: {
|
||||||
|
|||||||
@@ -6,88 +6,11 @@
|
|||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useContext, useEffect, useState } from 'react';
|
|
||||||
import List from '@mui/material/List';
|
|
||||||
import ListItemText from '@mui/material/ListItemText';
|
|
||||||
import ListSubheader from '@mui/material/ListSubheader';
|
|
||||||
import Dialog from '@mui/material/Dialog';
|
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
|
||||||
import DialogContentText from '@mui/material/DialogContentText';
|
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
|
||||||
import Button from '@mui/material/Button';
|
|
||||||
import ListItemButton from '@mui/material/ListItemButton';
|
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
|
||||||
import { styled } from '@mui/material';
|
|
||||||
import { t as translate } from 'i18next';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import requestManager from '@/lib/requests/RequestManager.ts';
|
import { useContext, useEffect } from 'react';
|
||||||
import makeToast from '@/components/util/Toast';
|
import NavbarContext, { useSetDefaultBackTo } from '@/components/context/NavbarContext.tsx';
|
||||||
import ThreeStateCheckboxInput from '@/components/atoms/ThreeStateCheckboxInput';
|
import { GlobalUpdateSettings } from '@/components/globalUpdate/GlobalUpdateSettings.tsx';
|
||||||
import NavbarContext, { useSetDefaultBackTo } from '@/components/context/NavbarContext';
|
import SearchSettings from '@/screens/settings/SearchSettings.tsx';
|
||||||
import SearchSettings from '@/screens/settings/SearchSettings';
|
|
||||||
import { IncludeInUpdate } from '@/lib/graphql/generated/graphql.ts';
|
|
||||||
import { TCategory } from '@/typings.ts';
|
|
||||||
|
|
||||||
const CategoriesDiv = styled('div')({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
maxHeight: '170px',
|
|
||||||
overflow: 'auto',
|
|
||||||
});
|
|
||||||
|
|
||||||
const booleanToIncludeInStatus = (status: boolean | null | undefined): IncludeInUpdate => {
|
|
||||||
switch (status) {
|
|
||||||
case false:
|
|
||||||
return IncludeInUpdate.Exclude;
|
|
||||||
case true:
|
|
||||||
return IncludeInUpdate.Include;
|
|
||||||
case null:
|
|
||||||
case undefined:
|
|
||||||
return IncludeInUpdate.Unset;
|
|
||||||
default:
|
|
||||||
throw new Error(`booleanToIncludeInStatus: unexpected IncludeInUpdate status "${status}"`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const includeInUpdateStatusToBoolean = (status: IncludeInUpdate): boolean | null => {
|
|
||||||
switch (status) {
|
|
||||||
case IncludeInUpdate.Exclude:
|
|
||||||
return false;
|
|
||||||
case IncludeInUpdate.Include:
|
|
||||||
return true;
|
|
||||||
case IncludeInUpdate.Unset:
|
|
||||||
return null;
|
|
||||||
default:
|
|
||||||
throw new Error(`includeInUpdateStatusToBoolean: unexpected IncludeInUpdate status "${status}"`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCategoryUpdateInfo = (
|
|
||||||
categories: TCategory[],
|
|
||||||
areIncluded: boolean,
|
|
||||||
unsetCategories: number,
|
|
||||||
allCategories: number,
|
|
||||||
error: any,
|
|
||||||
) => {
|
|
||||||
if (error) {
|
|
||||||
return translate('global.error.label.failed_to_load_data');
|
|
||||||
}
|
|
||||||
if (allCategories === -1) {
|
|
||||||
return translate('global.label.loading');
|
|
||||||
}
|
|
||||||
|
|
||||||
const noSpecificallyIncludedCategories = areIncluded && !categories.length && unsetCategories;
|
|
||||||
const includesAllCategories = categories.length === allCategories;
|
|
||||||
if (noSpecificallyIncludedCategories || includesAllCategories) {
|
|
||||||
return translate('extension.language.all');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!categories.length) {
|
|
||||||
return translate('global.label.none');
|
|
||||||
}
|
|
||||||
|
|
||||||
return categories.map((category) => category.name).join(', ');
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function LibrarySettings() {
|
export default function LibrarySettings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -100,153 +23,10 @@ export default function LibrarySettings() {
|
|||||||
|
|
||||||
useSetDefaultBackTo('settings');
|
useSetDefaultBackTo('settings');
|
||||||
|
|
||||||
const { data, error: requestError } = requestManager.useGetCategories();
|
|
||||||
const categories = data?.categories.nodes;
|
|
||||||
const [dialogCategories, setDialogCategories] = useState<TCategory[]>(categories ?? []);
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!categories) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDialogCategories(categories);
|
|
||||||
}, [categories]);
|
|
||||||
|
|
||||||
const unsetCategories: TCategory[] =
|
|
||||||
categories?.filter((category) => category.includeInUpdate === IncludeInUpdate.Unset) ?? [];
|
|
||||||
const excludedCategories: TCategory[] =
|
|
||||||
categories?.filter((category) => category.includeInUpdate === IncludeInUpdate.Exclude) ?? [];
|
|
||||||
const includedCategories: TCategory[] =
|
|
||||||
categories?.filter((category) => category.includeInUpdate === IncludeInUpdate.Include) ?? [];
|
|
||||||
const excludedCategoriesText = getCategoryUpdateInfo(
|
|
||||||
excludedCategories,
|
|
||||||
false,
|
|
||||||
unsetCategories.length,
|
|
||||||
categories?.length ?? -1,
|
|
||||||
requestError,
|
|
||||||
);
|
|
||||||
const includedCategoriesText = getCategoryUpdateInfo(
|
|
||||||
includedCategories,
|
|
||||||
true,
|
|
||||||
unsetCategories.length,
|
|
||||||
categories?.length ?? -1,
|
|
||||||
requestError,
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateCategory = (category: TCategory) =>
|
|
||||||
requestManager.updateCategory(category.id, { includeInUpdate: category.includeInUpdate }).response;
|
|
||||||
|
|
||||||
const updateCategories = async () => {
|
|
||||||
const categoriesToUpdate = dialogCategories.filter((category) => {
|
|
||||||
const currentCategory = categories?.find((currCategory) => currCategory.id === category.id);
|
|
||||||
|
|
||||||
if (!currentCategory) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentCategory.includeInUpdate !== category.includeInUpdate;
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsDialogOpen(false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Promise.all(categoriesToUpdate.map((category) => updateCategory(category)));
|
|
||||||
// TODO - update cache immediately
|
|
||||||
// mutate(categoriesEndpoint, [...dialogCategories], { revalidate: false });
|
|
||||||
} catch (error) {
|
|
||||||
makeToast(t('global.error.label.failed_to_save_changes'), 'error');
|
|
||||||
// mutate(categoriesEndpoint, [...categories]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeDialog = () => {
|
|
||||||
setDialogCategories(categories ?? []);
|
|
||||||
setIsDialogOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<List
|
|
||||||
subheader={
|
|
||||||
<ListSubheader component="div" id="nested-list-subheader">
|
|
||||||
{t('search.title.search')}
|
|
||||||
</ListSubheader>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SearchSettings />
|
<SearchSettings />
|
||||||
</List>
|
<GlobalUpdateSettings />
|
||||||
<List
|
|
||||||
subheader={
|
|
||||||
<ListSubheader component="div" id="nested-list-subheader">
|
|
||||||
{t('library.settings.global_update.title')}
|
|
||||||
</ListSubheader>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ListItemButton onClick={() => setIsDialogOpen(true)}>
|
|
||||||
<ListItemText
|
|
||||||
primary={t('category.title.categories')}
|
|
||||||
secondary={
|
|
||||||
<>
|
|
||||||
<span>
|
|
||||||
{t('library.settings.global_update.categories.label.include', {
|
|
||||||
includedCategoriesText,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{t('library.settings.global_update.categories.label.exclude', {
|
|
||||||
excludedCategoriesText,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
secondaryTypographyProps={{ style: { display: 'flex', flexDirection: 'column' } }}
|
|
||||||
/>
|
|
||||||
</ListItemButton>
|
|
||||||
</List>
|
|
||||||
|
|
||||||
<Dialog open={isDialogOpen} onClose={closeDialog}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogTitle sx={{ paddingLeft: 0 }}>{t('category.title.categories')}</DialogTitle>
|
|
||||||
<DialogContentText sx={{ paddingBottom: '10px' }}>
|
|
||||||
{t('library.settings.global_update.categories.label.info')}
|
|
||||||
</DialogContentText>
|
|
||||||
<CategoriesDiv>
|
|
||||||
{dialogCategories.map((category) => (
|
|
||||||
<ThreeStateCheckboxInput
|
|
||||||
key={category.id}
|
|
||||||
label={category.name}
|
|
||||||
checked={includeInUpdateStatusToBoolean(category.includeInUpdate)}
|
|
||||||
onChange={(checked) => {
|
|
||||||
const newIncludeState = booleanToIncludeInStatus(checked);
|
|
||||||
|
|
||||||
const categoryIndex = dialogCategories.findIndex(
|
|
||||||
(category_) => category_ === category,
|
|
||||||
);
|
|
||||||
const updatedDialogCategories: TCategory[] = [
|
|
||||||
...dialogCategories.slice(0, categoryIndex),
|
|
||||||
{
|
|
||||||
...category,
|
|
||||||
includeInUpdate: newIncludeState,
|
|
||||||
},
|
|
||||||
...dialogCategories.slice(categoryIndex + 1, dialogCategories.length),
|
|
||||||
];
|
|
||||||
|
|
||||||
setDialogCategories(updatedDialogCategories);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</CategoriesDiv>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={closeDialog} color="primary">
|
|
||||||
{t('global.button.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={updateCategories} color="primary">
|
|
||||||
{t('global.button.ok')}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,12 @@
|
|||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ListItem, ListItemText, Switch } from '@mui/material';
|
import { List, ListItem, ListItemText, Switch } from '@mui/material';
|
||||||
import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction';
|
import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction';
|
||||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import ListSubheader from '@mui/material/ListSubheader';
|
||||||
import { SearchMetadataKeys } from '@/typings';
|
import { SearchMetadataKeys } from '@/typings';
|
||||||
import { convertToGqlMeta, requestUpdateServerMetadata } from '@/util/metadata';
|
import { convertToGqlMeta, requestUpdateServerMetadata } from '@/util/metadata';
|
||||||
import { useSearchSettings } from '@/util/searchSettings';
|
import { useSearchSettings } from '@/util/searchSettings';
|
||||||
@@ -29,6 +30,13 @@ export default function SearchSettings() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
|
<List
|
||||||
|
subheader={
|
||||||
|
<ListSubheader component="div" id="library-search-filter">
|
||||||
|
{t('search.title.search')}
|
||||||
|
</ListSubheader>
|
||||||
|
}
|
||||||
|
>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<SearchIcon />
|
<SearchIcon />
|
||||||
@@ -42,5 +50,6 @@ export default function SearchSettings() {
|
|||||||
/>
|
/>
|
||||||
</ListItemSecondaryAction>
|
</ListItemSecondaryAction>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
</List>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user