Move category files into new folder

This commit is contained in:
schroda
2024-10-05 21:39:58 +02:00
parent 17e42ae012
commit 946c852828
22 changed files with 133 additions and 114 deletions

View File

@@ -0,0 +1,21 @@
/*
* 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 { LibraryOptions } from '@/modules/library/Library.types.ts';
import { CategoryMetaType, CategoryType } from '@/lib/graphql/generated/graphql.ts';
export interface ICategoryMetadata extends LibraryOptions {}
export type CategoryMetadataKeys = keyof ICategoryMetadata;
export type CategoryIdInfo = Pick<CategoryType, 'id'>;
export type CategoryNameInfo = Pick<CategoryType, 'name'>;
export type CategoryDefaultInfo = Pick<CategoryType, 'default'>;
export type CategoryUpdateInclusionInfo = Pick<CategoryType, 'includeInUpdate'>;
export type CategoryDownloadInclusionInfo = Pick<CategoryType, 'includeInDownload'>;
export type CategoryMetadataInfo = CategoryIdInfo & { meta: Pick<CategoryMetaType, 'key' | 'value'>[] };

View File

@@ -0,0 +1,218 @@
/*
* 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 '@/modules/core/components/inputs/ThreeStateCheckboxInput.tsx';
import { makeToast } from '@/lib/ui/Toast.ts';
import { IncludeOrExclude } from '@/lib/graphql/generated/graphql.ts';
import { requestManager } from '@/lib/requests/requests/RequestManager.ts';
import { CheckboxContainer } from '@/modules/core/components/inputs/CheckboxContainer.ts';
import {
CategoryDownloadInclusionInfo,
CategoryIdInfo,
CategoryNameInfo,
CategoryUpdateInclusionInfo,
} from '@/modules/category/Category.types.ts';
type CategoryType = CategoryIdInfo & CategoryNameInfo & CategoryUpdateInclusionInfo & CategoryDownloadInclusionInfo;
const booleanToIncludeOrExcludeStatus = (status: boolean | null | undefined): IncludeOrExclude => {
switch (status) {
case false:
return IncludeOrExclude.Exclude;
case true:
return IncludeOrExclude.Include;
case null:
case undefined:
return IncludeOrExclude.Unset;
default:
throw new Error(`booleanToIncludeInStatus: unexpected IncludeOrExclude status "${status}"`);
}
};
const includeInUpdateStatusToBoolean = (status: IncludeOrExclude): boolean | null => {
switch (status) {
case IncludeOrExclude.Exclude:
return false;
case IncludeOrExclude.Include:
return true;
case IncludeOrExclude.Unset:
return null;
default:
throw new Error(`includeInUpdateStatusToBoolean: unexpected IncludeOrExclude status "${status}"`);
}
};
const getCategoryUpdateInfo = (
categories: CategoryType[],
areIncluded: boolean,
unsetCategories: number,
allCategories: number,
) => {
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(', ');
};
type CategoryIncludeField = keyof Pick<CategoryType, 'includeInUpdate' | 'includeInDownload'>;
export type CategoriesInclusionSettingProps = {
categories: CategoryType[];
includeField: CategoryIncludeField;
dialogText?: string;
};
export const CategoriesInclusionSetting = ({
categories,
includeField,
dialogText,
}: CategoriesInclusionSettingProps) => {
const { t } = useTranslation();
const [dialogCategories, setDialogCategories] = useState(categories);
const [isDialogOpen, setIsDialogOpen] = useState(false);
useEffect(() => {
if (!categories) {
return;
}
setDialogCategories(categories);
}, [categories]);
const unsetCategories = categories.filter((category) => category[includeField] === IncludeOrExclude.Unset);
const excludedCategories = categories.filter((category) => category[includeField] === IncludeOrExclude.Exclude);
const includedCategories = categories.filter((category) => category[includeField] === IncludeOrExclude.Include);
const excludedCategoriesText = getCategoryUpdateInfo(
excludedCategories,
false,
unsetCategories.length,
categories.length,
);
const includedCategoriesText = getCategoryUpdateInfo(
includedCategories,
true,
unsetCategories.length,
categories.length,
);
const updateCategory = (category: CategoryType) =>
requestManager.updateCategory(category.id, { [includeField]: category[includeField] }).response;
const updateCategories = async () => {
const categoriesToUpdate = dialogCategories.filter((category) => {
const currentCategory = categories?.find((currCategory) => currCategory.id === category.id);
if (!currentCategory) {
return false;
}
return currentCategory[includeField] !== category[includeField];
});
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.category_other')}
secondary={
<>
<span>
{t('category.settings.inclusion.label.include', {
includedCategoriesText,
})}
</span>
<span>
{t('category.settings.inclusion.label.exclude', {
excludedCategoriesText,
})}
</span>
</>
}
secondaryTypographyProps={{ style: { display: 'flex', flexDirection: 'column' } }}
/>
</ListItemButton>
<Dialog open={isDialogOpen} onClose={closeDialog}>
<DialogContent>
<DialogTitle sx={{ paddingLeft: 0 }}>{t('category.title.category_other')}</DialogTitle>
{dialogText && <DialogContentText sx={{ paddingBottom: '10px' }}>{dialogText}</DialogContentText>}
<CheckboxContainer>
{dialogCategories.map((category) => (
<ThreeStateCheckboxInput
key={category.id}
label={category.name}
checked={includeInUpdateStatusToBoolean(category[includeField])}
onChange={(checked) => {
const newIncludeState = booleanToIncludeOrExcludeStatus(checked);
const categoryIndex = dialogCategories.findIndex(
(category_) => category_ === category,
);
const updatedDialogCategories = [
...dialogCategories.slice(0, categoryIndex),
{
...category,
[includeField]: 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>
</>
);
};

View File

@@ -0,0 +1,252 @@
/*
* 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 { useState, useEffect, useMemo } from 'react';
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 { Link } from 'react-router-dom';
import Stack from '@mui/material/Stack';
import { requestManager } from '@/lib/requests/requests/RequestManager.ts';
import { Mangas } from '@/modules/manga/services/Mangas.ts';
import { useSelectableCollection } from '@/modules/collection/hooks/useSelectableCollection.ts';
import { ThreeStateCheckboxInput } from '@/modules/core/components/inputs/ThreeStateCheckboxInput.tsx';
import { Categories } from '@/modules/category/services/Categories.ts';
import { CheckboxInput } from '@/modules/core/components/inputs/CheckboxInput.tsx';
import { makeToast } from '@/lib/ui/Toast.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { updateMetadataServerSettings } from '@/lib/metadata/metadataServerSettings.ts';
import {
GetCategoriesBaseQuery,
GetCategoriesBaseQueryVariables,
GetMangaCategoriesQuery,
GetMangaCategoriesQueryVariables,
} from '@/lib/graphql/generated/graphql.ts';
import { GET_CATEGORIES_BASE } from '@/lib/graphql/queries/CategoryQuery.ts';
import { GET_MANGA_CATEGORIES } from '@/lib/graphql/queries/MangaQuery.ts';
type BaseProps = {
open: boolean;
onClose: (didUpdateCategories: boolean, addToCategories?: number[], removeFromCategories?: number[]) => void;
};
type SingleMangaModeProps = {
mangaId: number;
addToLibrary?: boolean;
};
type MultiMangaModeProps = {
mangaIds: number[];
};
export type CategorySelectProps =
| (BaseProps & SingleMangaModeProps & PropertiesNever<MultiMangaModeProps>)
| (BaseProps & PropertiesNever<SingleMangaModeProps> & MultiMangaModeProps);
const useGetMangaCategoryIds = (mangaId: number | undefined): number[] => {
const { data: mangaResult } = requestManager.useGetManga<GetMangaCategoriesQuery, GetMangaCategoriesQueryVariables>(
GET_MANGA_CATEGORIES,
mangaId ?? -1,
{ skip: mangaId === undefined },
);
return useMemo(() => {
if (mangaId === undefined || !mangaResult) {
return [];
}
return Categories.getIds(mangaResult.manga.categories.nodes);
}, [mangaResult?.manga.categories.nodes, mangaId]);
};
const getCategoryCheckedState = (
categoryId: number,
categoriesToAdd: number[],
categoriesToRemove: number[],
isSingleSelectionMode: boolean,
): boolean | undefined => {
if (categoriesToAdd.includes(categoryId)) {
return true;
}
if (isSingleSelectionMode) {
return undefined;
}
if (categoriesToRemove.includes(categoryId)) {
return false;
}
return undefined;
};
export function CategorySelect(props: CategorySelectProps) {
const { t } = useTranslation();
const { open, onClose, mangaId, mangaIds: passedMangaIds, addToLibrary = false } = props;
const isSingleSelectionMode = mangaId !== undefined;
const mangaIds = passedMangaIds ?? [mangaId];
const [doNotShowAddToLibraryDialogAgain, setDoNotShowAddToLibraryDialogAgain] = useState(false);
const mangaCategoryIds = useGetMangaCategoryIds(mangaId);
const { data } = requestManager.useGetCategories<GetCategoriesBaseQuery, GetCategoriesBaseQueryVariables>(
GET_CATEGORIES_BASE,
);
const categoriesData = data?.categories.nodes;
const allCategories = useMemo(() => Categories.getUserCreated(categoriesData ?? []), [categoriesData]);
const defaultCategoryIds = useMemo(
() => (addToLibrary ? Categories.getIds(Categories.getDefaults(allCategories)) : []),
[allCategories],
);
const { handleSelection, setSelectionForKey, getSelectionForKey } = useSelectableCollection<
number,
'categoriesToAdd' | 'categoriesToRemove'
>(allCategories.length, {
currentKey: 'categoriesToAdd',
initialState: {
categoriesToAdd: [...mangaCategoryIds, ...defaultCategoryIds],
categoriesToRemove: [],
},
});
useEffect(() => {
setSelectionForKey('categoriesToAdd', [...mangaCategoryIds, ...defaultCategoryIds]);
setSelectionForKey('categoriesToRemove', []);
}, [mangaCategoryIds]);
const categoriesToAdd = getSelectionForKey('categoriesToAdd');
const categoriesToRemove = getSelectionForKey('categoriesToRemove');
const handleCancel = () => {
setSelectionForKey('categoriesToAdd', mangaCategoryIds);
setSelectionForKey('categoriesToRemove', []);
onClose(false);
};
const handleOk = () => {
const addToCategories = isSingleSelectionMode
? categoriesToAdd.filter((categoryId) => !mangaCategoryIds.includes(categoryId))
: categoriesToAdd;
const removeFromCategories = isSingleSelectionMode
? mangaCategoryIds.filter((categoryId) => !categoriesToAdd.includes(categoryId))
: categoriesToRemove;
onClose(true, addToCategories, removeFromCategories);
if (doNotShowAddToLibraryDialogAgain) {
updateMetadataServerSettings('showAddToLibraryCategorySelectDialog', false).catch(() =>
makeToast(t('search.error.label.failed_to_save_settings'), 'error'),
);
}
const isUpdateRequired = !!addToCategories.length || !!removeFromCategories.length;
if (!isUpdateRequired) {
return;
}
if (addToLibrary) {
// categories get updated in MangaDetails
return;
}
Mangas.performAction('change_categories', mangaIds, {
changeCategoriesPatch: {
addToCategories,
removeFromCategories,
},
}).catch(defaultPromiseErrorHandler('CategorySelect::handleOk'));
};
return (
<Dialog
sx={{
'.MuiDialog-paper': {
maxHeight: 435,
width: '80%',
},
}}
maxWidth="xs"
open={open}
>
<DialogTitle>{t('category.title.set_categories')}</DialogTitle>
<DialogContent dividers>
<FormGroup>
{allCategories.length === 0 && <span>{t('category.error.no_categories_found.label.info')}</span>}
{allCategories.map((category) => (
<ThreeStateCheckboxInput
checked={getCategoryCheckedState(
category.id,
categoriesToAdd,
categoriesToRemove,
isSingleSelectionMode,
)}
onChange={(checked) => {
handleSelection(category.id, false, { key: 'categoriesToAdd' });
handleSelection(category.id, false, { key: 'categoriesToRemove' });
if (checked) {
handleSelection(category.id, true, { key: 'categoriesToAdd' });
}
if (checked === false) {
handleSelection(category.id, true, { key: 'categoriesToRemove' });
}
}}
label={category.name}
key={category.id}
/>
))}
</FormGroup>
</DialogContent>
<DialogActions>
<Stack sx={{ width: '100%' }}>
{addToLibrary && (
<CheckboxInput
sx={{ margin: 0 }}
size="small"
label={t('global.button.dont_show_dialog_again')}
onChange={(e) => setDoNotShowAddToLibraryDialogAgain(e.target.checked)}
/>
)}
<Stack
direction="row"
sx={{
justifyContent: 'space-between',
alignItems: 'end',
width: '100%',
}}
>
<Button component={Link} to="/settings/categories">
{t(allCategories.length ? 'global.button.edit' : 'global.button.create')}
</Button>
<Stack direction="row">
<Button autoFocus onClick={handleCancel} color="primary">
{t('global.button.cancel')}
</Button>
{!!allCategories.length && (
<Button onClick={handleOk} color="primary">
{t('global.button.ok')}
</Button>
)}
</Stack>
</Stack>
</Stack>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,74 @@
/*
* 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 IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import { DraggableProvided } from 'react-beautiful-dnd';
import DragHandleIcon from '@mui/icons-material/DragHandle';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import { useTranslation } from 'react-i18next';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { requestManager } from '@/lib/requests/requests/RequestManager.ts';
import { CategoryType } from '@/lib/graphql/generated/graphql.ts';
export const CategorySettingsCard = ({
category,
provided,
onEdit,
}: {
category: Pick<CategoryType, 'id' | 'name'>;
provided: DraggableProvided;
onEdit: () => void;
}) => {
const { t } = useTranslation();
const deleteCategory = () => {
requestManager.deleteCategory(category.id);
};
return (
<Box sx={{ p: 1, pb: 0 }} {...provided.draggableProps} {...provided.dragHandleProps} ref={provided.innerRef}>
<Card>
<CardContent
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: 1.5,
'&:last-child': {
paddingBottom: 1.5,
},
gap: 2,
}}
>
<DragHandleIcon />
<Typography sx={{ flexGrow: 1 }} variant="h6" component="h2">
{category.name}
</Typography>
<Stack sx={{ flexDirection: 'row' }}>
<Tooltip title={t('global.button.edit')}>
<IconButton component={Box} onClick={onEdit} size="large">
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title={t('chapter.action.download.delete.label.action')}>
<IconButton component={Box} onClick={deleteCategory} size="large">
<DeleteIcon />
</IconButton>
</Tooltip>
</Stack>
</CardContent>
</Card>
</Box>
);
};

View File

@@ -0,0 +1,43 @@
/*
* 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 { useMemo, useState } from 'react';
import { CategorySelect, CategorySelectProps } from '@/modules/category/components/CategorySelect.tsx';
export const useCategorySelect = ({
mangaId,
mangaIds,
onClose,
addToLibrary,
}: Omit<CategorySelectProps, 'open' | 'onClose'> & Pick<Partial<CategorySelectProps>, 'onClose'>) => {
const [isCategorySelectOpen, setIsCategorySelectOpen] = useState(false);
const CategorySelectComponent = useMemo(() => {
if (!isCategorySelectOpen) {
return null;
}
return (
<CategorySelect
open={isCategorySelectOpen}
onClose={(...args) => {
setIsCategorySelectOpen(false);
onClose?.(...args);
}}
mangaId={mangaId!} // either mangaId or mangaIds is undefined, however, ts is not able to infer it correctly and raises an error
mangaIds={mangaIds as undefined}
addToLibrary={addToLibrary}
/>
);
}, [mangaId, mangaIds, addToLibrary, onClose, isCategorySelectOpen]);
return {
openCategorySelect: setIsCategorySelectOpen,
CategorySelectComponent,
};
};

View File

@@ -0,0 +1,202 @@
/*
* 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 { useContext, useLayoutEffect, useMemo, useState } from 'react';
import { DragDropContext, Draggable, DropResult } from 'react-beautiful-dnd';
import { useTheme } from '@mui/material/styles';
import Fab from '@mui/material/Fab';
import AddIcon from '@mui/icons-material/Add';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import Checkbox from '@mui/material/Checkbox';
import FormControlLabel from '@mui/material/FormControlLabel';
import { useTranslation } from 'react-i18next';
import Box from '@mui/material/Box';
import { requestManager } from '@/lib/requests/requests/RequestManager.ts';
import { StrictModeDroppable } from '@/modules/core/components/StrictModeDroppable.tsx';
import { DEFAULT_FULL_FAB_HEIGHT } from '@/modules/core/components/buttons/StyledFab.tsx';
import { NavBarContext } from '@/components/context/NavbarContext.tsx';
import { LoadingPlaceholder } from '@/modules/core/components/placeholder/LoadingPlaceholder.tsx';
import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { GetCategoriesSettingsQuery, GetCategoriesSettingsQueryVariables } from '@/lib/graphql/generated/graphql.ts';
import { GET_CATEGORIES_SETTINGS } from '@/lib/graphql/queries/CategoryQuery.ts';
import { CategorySettingsCard } from '@/modules/category/components/CategorySettingsCard.tsx';
import { CategoryIdInfo } from '@/modules/category/Category.types.ts';
export function CategorySettings() {
const { t } = useTranslation();
const { setTitle, setAction } = useContext(NavBarContext);
useLayoutEffect(() => {
setTitle(t('category.title.category_other'));
setAction(null);
return () => {
setTitle('');
setAction(null);
};
}, [t]);
const { data, loading, error, refetch } = requestManager.useGetCategories<
GetCategoriesSettingsQuery,
GetCategoriesSettingsQueryVariables
>(GET_CATEGORIES_SETTINGS, { notifyOnNetworkStatusChange: true });
const categories = useMemo(() => {
const res = [...(data?.categories.nodes ?? [])];
if (res.length > 0 && res[0].name === 'Default') {
res.shift();
}
return res;
}, [data]);
const [categoryToEdit, setCategoryToEdit] = useState<number>(-1); // -1 means new category
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
const [dialogName, setDialogName] = useState<string>('');
const [dialogDefault, setDialogDefault] = useState<boolean>(false);
const [reorderCategory, { reset: revertReorder }] = requestManager.useReorderCategory();
const theme = useTheme();
const categoryReorder = (list: CategoryIdInfo[], from: number, to: number) => {
const reorderedCategory = list[from];
reorderCategory({ variables: { input: { id: reorderedCategory.id, position: to + 1 } } }).catch(() =>
revertReorder(),
);
};
const onDragEnd = (result: DropResult) => {
// dropped outside the list?
if (!result.destination) {
return;
}
categoryReorder(categories, result.source.index, result.destination.index);
};
const resetDialog = () => {
setDialogName('');
setDialogDefault(false);
setCategoryToEdit(-1);
};
const handleDialogOpen = () => {
resetDialog();
setDialogOpen(true);
};
const handleEditDialogOpen = (index: number) => {
setDialogName(categories[index].name);
setDialogDefault(categories[index].default);
setCategoryToEdit(index);
setDialogOpen(true);
};
const handleDialogCancel = () => {
setDialogOpen(false);
};
const handleDialogSubmit = () => {
setDialogOpen(false);
if (categoryToEdit === -1) {
requestManager.createCategory({ name: dialogName, default: dialogDefault });
} else {
const category = categories[categoryToEdit];
requestManager.updateCategory(category.id, { name: dialogName, default: dialogDefault });
}
};
if (loading) {
return <LoadingPlaceholder />;
}
if (error) {
return (
<EmptyViewAbsoluteCentered
message={t('category.error.label.request_failure')}
messageExtra={error.message}
retry={() => refetch().catch(defaultPromiseErrorHandler('CategorySettings::refetch'))}
/>
);
}
return (
<>
<DragDropContext onDragEnd={onDragEnd}>
<StrictModeDroppable droppableId="droppable">
{(droppableProvided) => (
<Box ref={droppableProvided.innerRef} sx={{ paddingBottom: DEFAULT_FULL_FAB_HEIGHT }}>
{categories.map((category, index) => (
<Draggable key={category.id} draggableId={category.id.toString()} index={index}>
{(draggableProvided) => (
<CategorySettingsCard
provided={draggableProvided}
category={category}
onEdit={() => handleEditDialogOpen(index)}
/>
)}
</Draggable>
))}
{droppableProvided.placeholder}
</Box>
)}
</StrictModeDroppable>
</DragDropContext>
<Fab
color="primary"
aria-label="add"
style={{
position: 'fixed',
bottom: theme.spacing(2),
right: theme.spacing(2),
}}
onClick={handleDialogOpen}
>
<AddIcon />
</Fab>
<Dialog open={dialogOpen} onClose={handleDialogCancel}>
<DialogTitle id="form-dialog-title">
{categoryToEdit === -1
? t('category.dialog.title.new_category')
: t('category.dialog.title.edit_category')}
</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
id="name"
label={t('category.label.category_name')}
type="text"
fullWidth
value={dialogName}
onChange={(e) => setDialogName(e.target.value)}
/>
<FormControlLabel
control={
<Checkbox checked={dialogDefault} onChange={(e) => setDialogDefault(e.target.checked)} />
}
label={t('category.label.use_as_default_category')}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleDialogCancel} color="primary">
{t('global.button.cancel')}
</Button>
<Button onClick={handleDialogSubmit} color="primary">
{t('global.button.submit')}
</Button>
</DialogActions>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,25 @@
/*
* 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 { CategoryDefaultInfo, CategoryIdInfo } from '@/modules/category/Category.types.ts';
export const DEFAULT_CATEGORY_ID = 0;
export class Categories {
static getIds(categories: CategoryIdInfo[]): number[] {
return categories.map((category) => category.id);
}
static getUserCreated<Category extends CategoryIdInfo>(categories: Category[]): Category[] {
return categories.filter((category) => category.id !== DEFAULT_CATEGORY_ID);
}
static getDefaults<Category extends CategoryDefaultInfo>(categories: Category[]): Category[] {
return categories.filter((category) => category.default);
}
}

View File

@@ -0,0 +1,89 @@
/*
* 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 { AllowedMetadataValueTypes, AppMetadataKeys, GqlMetaHolder, Metadata } from '@/typings.ts';
import { jsonSaveParse } from '@/lib/HelperFunctions.ts';
import { convertFromGqlMeta, getMetadataFrom, requestUpdateCategoryMetadata } from '@/lib/metadata/metadata.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { GridLayout } from '@/modules/library/contexts/LibraryOptionsContext.tsx';
import { LibraryOptions } from '@/modules/library/Library.types.ts';
import { CategoryIdInfo, CategoryMetadataKeys, ICategoryMetadata } from '@/modules/category/Category.types.ts';
export const getDefaultCategoryMetadata = (): ICategoryMetadata => ({
// display options
showContinueReadingButton: false,
showDownloadBadge: false,
showUnreadBadge: false,
gridLayout: GridLayout.Compact,
// sort options
sortDesc: undefined,
sortBy: undefined,
// filter options
hasDownloadedChapters: undefined,
hasBookmarkedChapters: undefined,
hasUnreadChapters: undefined,
hasDuplicateChapters: undefined,
hasTrackerBinding: {},
hasStatus: {} as LibraryOptions['hasStatus'],
});
const convertAppMetadataToGqlMetadata = (
metadata: Partial<ICategoryMetadata>,
): Metadata<string, AllowedMetadataValueTypes> => ({
...metadata,
hasTrackerBinding: metadata.hasTrackerBinding ? JSON.stringify(metadata.hasTrackerBinding) : undefined,
hasStatus: metadata.hasStatus ? JSON.stringify(metadata.hasStatus) : undefined,
});
const convertGqlMetadataToAppMetadata = (
metadata: Partial<Metadata<AppMetadataKeys, AllowedMetadataValueTypes>>,
): ICategoryMetadata => ({
...(metadata as unknown as ICategoryMetadata),
hasTrackerBinding:
jsonSaveParse<ICategoryMetadata['hasTrackerBinding']>(metadata.hasTrackerBinding as string) ??
(undefined as any),
hasStatus: jsonSaveParse<ICategoryMetadata['hasStatus']>(metadata.hasStatus as string) ?? (undefined as any),
});
const getCategoryMetadataWithDefaultValueFallback = (
meta?: Metadata,
defaultMetadata: ICategoryMetadata = getDefaultCategoryMetadata(),
applyMetadataMigration: boolean = true,
): ICategoryMetadata =>
convertGqlMetadataToAppMetadata(
getMetadataFrom({ meta }, convertAppMetadataToGqlMetadata(defaultMetadata), applyMetadataMigration),
);
export const getCategoryMetadata = (
{ meta }: GqlMetaHolder = {},
defaultMetadata?: ICategoryMetadata,
applyMetadataMigration?: boolean,
): ICategoryMetadata =>
getCategoryMetadataWithDefaultValueFallback(convertFromGqlMeta(meta), defaultMetadata, applyMetadataMigration);
export const updateCategoryMetadata = async <
MetadataKeys extends CategoryMetadataKeys = CategoryMetadataKeys,
MetadataKey extends MetadataKeys = MetadataKeys,
>(
category: CategoryIdInfo & GqlMetaHolder,
metadataKey: MetadataKey,
value: ICategoryMetadata[MetadataKey],
): Promise<void[]> =>
requestUpdateCategoryMetadata(category, [
[metadataKey, convertAppMetadataToGqlMetadata({ [metadataKey]: value })[metadataKey]],
]);
export const createUpdateCategoryMetadata =
<Settings extends CategoryMetadataKeys>(
category: CategoryIdInfo & GqlMetaHolder,
handleError: (error: any) => void = defaultPromiseErrorHandler('createUpdateCategoryMetadata'),
): ((...args: OmitFirst<Parameters<typeof updateCategoryMetadata<Settings>>>) => Promise<void | void[]>) =>
(metadataKey, value) =>
updateCategoryMetadata(category, metadataKey, value).catch(handleError);