Custom reader profiles

This commit is contained in:
schroda
2024-10-28 18:03:29 +01:00
parent 0855fbcf23
commit 0ae9d9ebb2
32 changed files with 655 additions and 106 deletions

View File

@@ -67,7 +67,14 @@ const getCategoryMetadataWithDefaultValueFallback = (
useEffectFn?: typeof useEffect,
): ICategoryMetadata =>
convertGqlMetadataToAppMetadata(
getMetadataFrom('category', meta, convertAppMetadataToGqlMetadata(defaultMetadata), true, useEffectFn),
getMetadataFrom(
'category',
meta,
convertAppMetadataToGqlMetadata(defaultMetadata),
undefined,
true,
useEffectFn,
),
);
const getMetadata = (

View File

@@ -15,13 +15,21 @@ export enum GridLayout {
List = 2,
}
export type ValueToDisplayData<Value extends string | number> = Record<
Value,
{
title: TranslationKey;
icon: ReactNode;
}
>;
interface DisplayDataTranslationKey {
isTitleString?: never;
title: TranslationKey;
icon: ReactNode;
}
interface DisplayDataString {
isTitleString: true;
title: string;
icon: ReactNode;
}
type DisplayData = DisplayDataTranslationKey | DisplayDataString;
export type ValueToDisplayData<Value extends string | number> = Record<Value, DisplayData>;
export interface MultiValueButtonBaseProps<Value extends string | number> {
value: Value;

View File

@@ -35,7 +35,9 @@ export const ButtonSelect = <Value extends string | number>({
variant={displayValue === value ? 'contained' : 'outlined'}
startIcon={valueToDisplayData[displayValue].icon}
>
{t(valueToDisplayData[displayValue].title)}
{valueToDisplayData[displayValue].isTitleString
? valueToDisplayData[displayValue].title
: t(valueToDisplayData[displayValue].title)}
</Button>
))}
</Stack>

View File

@@ -64,7 +64,9 @@ export const ValueRotationButton = <Value extends string | number>({
startIcon={valueToDisplayData[value].icon}
size="large"
>
{t(valueToDisplayData[value].title)}
{valueToDisplayData[value].isTitleString
? valueToDisplayData[value].title
: t(valueToDisplayData[value].title)}
</Button>
);
};

View File

@@ -66,9 +66,9 @@ type MutableListSettingProps = Pick<TextSettingProps, 'settingName' | 'placehold
description?: string;
dialogDisclaimer?: JSX.Element | string;
addItemButtonTitle?: string;
handleChange: (values: string[]) => void;
handleChange: (values: string[], removedValues: string[]) => void;
allowDuplicates?: boolean;
validateItem?: (value: string) => boolean;
validateItem?: (value: string, tmpValues?: string[]) => boolean;
invalidItemError?: string;
};
@@ -128,7 +128,7 @@ export const MutableListSetting = ({
return;
}
if (!validateItem(newValue)) {
if (!validateItem?.(newValue, dialogValues)) {
makeToast(invalidItemError ?? t('global.error.label.invalid_input'), 'error');
return;
}
@@ -138,7 +138,11 @@ export const MutableListSetting = ({
const saveChanges = () => {
closeDialog(true);
handleChange(dialogValues.filter((dialogValue) => dialogValue !== ''));
const updatedValues = dialogValues.filter((dialogValue) => dialogValue !== '');
const removedValues = values.filter((value) => !updatedValues.includes(value));
handleChange(updatedValues, removedValues);
};
return (
@@ -193,6 +197,7 @@ export const MutableListSetting = ({
<List>
{dialogValues.map((dialogValue, index) => (
<MutableListItem
key={dialogValue}
settingName=""
placeholder={placeholder}
handleChange={(newValue: string) => updateSetting(index, newValue)}
@@ -230,6 +235,7 @@ export const MutableListSetting = ({
handleChange={(newValue: string) => updateSetting(dialogValues.length, newValue)}
isDialogOpen={isAddItemDialogOpen}
setIsDialogOpen={setIsAddItemDialogOpen}
validate={(value) => validateItem?.(value, dialogValues) ?? true}
/>
)}
</>

View File

@@ -47,6 +47,8 @@ export const TextSettingDialog = ({
const [dialogValue, setDialogValue] = useState(value ?? '');
const [isValidValue, setIsValidValue] = useState(true);
const error = !isValidValue && !!dialogValue.length;
const TextFieldComponent = useMemo(() => (isPassword ? PasswordTextField : TextField), [isPassword]);
useEffect(() => {
@@ -85,8 +87,8 @@ export const TextSettingDialog = ({
autoFocus
placeholder={placeholder}
value={dialogValue}
error={!isValidValue}
helperText={!isValidValue ? t('global.error.label.invalid_input') : ''}
error={error}
helperText={error ? t('global.error.label.invalid_input') : ''}
onChange={(e) => {
const newValue = e.target.value;

View File

@@ -9,7 +9,7 @@
import { AppMetadataKeys, IMetadataMigration } from '@/modules/metadata/Metadata.types.ts';
import { ReaderPageScaleMode, ReadingMode } from '@/modules/reader/types/Reader.types.ts';
export const APP_METADATA_KEY_PREFIX = 'webUI_';
export const APP_METADATA_KEY_PREFIX = 'webUI';
const APP_METADATA_OBJECT: Record<AppMetadataKeys, undefined> = {
migration: undefined,
@@ -66,6 +66,9 @@ const APP_METADATA_OBJECT: Record<AppMetadataKeys, undefined> = {
shouldShowPageNumber: undefined,
backgroundColor: undefined,
pageGap: undefined,
profiles: undefined,
readingModesDefaultProfile: undefined,
defaultProfile: undefined,
};
export const VALID_APP_METADATA_KEYS = Object.keys(APP_METADATA_OBJECT);
@@ -131,6 +134,7 @@ export const GLOBAL_METADATA_KEYS: AppMetadataKeys[] = [
'exitMode',
'customFilter',
'shouldSkipDupChapters',
'profiles',
];
/**

View File

@@ -31,8 +31,8 @@ export const applyAppKeyPrefixMigration = (meta: Metadata, migration: IMetadataM
const { oldPrefix, newPrefix } = migration.appKeyPrefix;
const oldAppMetadata = getAppMetadataFrom(meta, oldPrefix);
const newAppMetadata = getAppMetadataFrom(meta, newPrefix);
const oldAppMetadata = getAppMetadataFrom(meta, undefined, oldPrefix);
const newAppMetadata = getAppMetadataFrom(meta, undefined, newPrefix);
const missingMetadataKeys = Object.keys(oldAppMetadata).filter((key) => !Object.keys(newAppMetadata).includes(key));
@@ -54,7 +54,7 @@ const applyMetadataValueMigration = (meta: Metadata, migration: IMetadataMigrati
return migratedMetadata;
}
const appMetadata = getAppMetadataFrom(meta, appKeyPrefix);
const appMetadata = getAppMetadataFrom(meta, undefined, appKeyPrefix);
const metadataValueChanges = migration.values;
metadataValueChanges.forEach(({ key, oldValue, newValue }) => {
@@ -72,11 +72,11 @@ const applyMetadataValueMigration = (meta: Metadata, migration: IMetadataMigrati
return;
}
if (!doesMetadataKeyExistIn(meta, key, appKeyPrefix)) {
if (!doesMetadataKeyExistIn(meta, key, undefined, appKeyPrefix)) {
return;
}
migrateValue(getMetadataKey(key, appKeyPrefix));
migrateValue(getMetadataKey(key, undefined, appKeyPrefix));
});
return migratedMetadata;

View File

@@ -13,7 +13,7 @@ import {
METADATA_MIGRATIONS,
VALID_APP_METADATA_KEYS,
} from '@/modules/metadata/Metadata.constants.ts';
import { DEFAULT_DEVICE, getActiveDevice } from '@/modules/device/services/Device.ts';
import { getActiveDevice } from '@/modules/device/services/Device.ts';
import { applyMetadataMigrations } from '@/modules/metadata/services/MetadataMigrations.ts';
import { convertToGqlMeta, convertValueFromMetadata } from '@/modules/metadata/services/MetadataConverter.ts';
import {
@@ -44,21 +44,44 @@ import { SourceType } from '@/lib/graphql/generated/graphql.ts';
export const extractOriginalKey = (key: string) => key.split('_').slice(-1)[0];
export const getMetadataKey = (key: string, appPrefix: string = APP_METADATA_KEY_PREFIX) => {
/**
* Returns the key with the provided prefixes.
*
* In case the key is not a global key ({@link GLOBAL_METADATA_KEYS}), the active device name ({@link getActiveDevice})
* will be added as the second prefix.
*
* **Important**
*
* "default" (case-insensitive) is a reserved value and will be handled as if there is no prefix to add
*
* The format is <prefixes>\_<key>
*
* e.g.: <base_prefix>\_[device_name]\_[optional_prefix1]\_[optional_prefix2]\_<key>
*/
export const getMetadataKey = (key: string, prefixes: string[] = [], appPrefix: string = APP_METADATA_KEY_PREFIX) => {
const isGlobalMetadataKey = GLOBAL_METADATA_KEYS.includes(key as AppMetadataKeys);
const addActiveDevicePrefix = !isGlobalMetadataKey && getActiveDevice() !== DEFAULT_DEVICE;
const addActiveDevicePrefix = !isGlobalMetadataKey;
return `${appPrefix}${addActiveDevicePrefix ? `${getActiveDevice()}_` : ''}${key}`;
const finalPrefix = [appPrefix, ...(addActiveDevicePrefix ? [getActiveDevice()] : []), ...prefixes].filter(
(prefix) => prefix.toLowerCase() !== 'default',
);
return `${finalPrefix.join('_')}_${key}`;
};
export const doesMetadataKeyExistIn = (meta: Metadata | undefined, key: string, appPrefix?: string): boolean =>
Object.prototype.hasOwnProperty.call(meta ?? {}, getMetadataKey(key, appPrefix));
export const doesMetadataKeyExistIn = (
meta: Metadata | undefined,
key: string,
prefixes?: string[],
appPrefix?: string,
): boolean => Object.prototype.hasOwnProperty.call(meta ?? {}, getMetadataKey(key, prefixes, appPrefix));
export const getMetadataValueFrom = <Key extends AppMetadataKeys, Value extends AllowedMetadataValueTypes>(
{ meta }: MetadataHolder,
key: Key,
defaultValue?: Value,
applyMigrations: boolean = true,
prefixes?: string[],
applyMigrations?: boolean,
): Value | undefined => {
const requiresMigration = Number(meta?.migration) !== METADATA_MIGRATIONS.length;
const doMigration = requiresMigration && applyMigrations;
@@ -66,13 +89,13 @@ export const getMetadataValueFrom = <Key extends AppMetadataKeys, Value extends
if (
metadata === undefined ||
!doesMetadataKeyExistIn(metadata, key) ||
metadata[getMetadataKey(key)] === undefined
!doesMetadataKeyExistIn(metadata, key, prefixes) ||
metadata[getMetadataKey(key, prefixes)] === undefined
) {
return defaultValue;
}
return convertValueFromMetadata(metadata[getMetadataKey(key)]);
return convertValueFromMetadata(metadata[getMetadataKey(key, prefixes)]);
};
const getMetadataUpdateFunction = (
@@ -172,6 +195,7 @@ export function getMetadataFrom<METADATA extends Partial<Metadata<AppMetadataKey
type: 'global',
metadataHolder: MetadataHolder,
metadataWithDefaultValues: METADATA,
prefixes?: string[],
applyMigrations?: boolean,
useEffectFn?: typeof useEffect,
): METADATA;
@@ -179,6 +203,7 @@ export function getMetadataFrom<METADATA extends Partial<Metadata<AppMetadataKey
type: 'manga',
metadataHolder: MangaIdInfo & MetadataHolder,
metadataWithDefaultValues: METADATA,
prefixes?: string[],
applyMigrations?: boolean,
useEffectFn?: typeof useEffect,
): METADATA;
@@ -186,6 +211,7 @@ export function getMetadataFrom<METADATA extends Partial<Metadata<AppMetadataKey
type: 'chapter',
metadataHolder: ChapterIdInfo & MetadataHolder,
metadataWithDefaultValues: METADATA,
prefixes?: string[],
applyMigrations?: boolean,
useEffectFn?: typeof useEffect,
): METADATA;
@@ -193,6 +219,7 @@ export function getMetadataFrom<METADATA extends Partial<Metadata<AppMetadataKey
type: 'category',
metadataHolder: CategoryIdInfo & MetadataHolder,
metadataWithDefaultValues: METADATA,
prefixes?: string[],
applyMigrations?: boolean,
useEffectFn?: typeof useEffect,
): METADATA;
@@ -200,6 +227,7 @@ export function getMetadataFrom<METADATA extends Partial<Metadata<AppMetadataKey
type: 'source',
metadataHolder: Pick<SourceType, 'id'> & MetadataHolder,
metadataWithDefaultValues: METADATA,
prefixes?: string[],
applyMigrations?: boolean,
useEffectFn?: typeof useEffect,
): METADATA;
@@ -212,13 +240,14 @@ export function getMetadataFrom<METADATA extends Partial<Metadata<AppMetadataKey
| (CategoryIdInfo & MetadataHolder)
| (Pick<SourceType, 'id'> & MetadataHolder),
metadataWithDefaultValues: METADATA,
applyMigrations?: boolean,
prefixes?: string[],
applyMigrations: boolean = true,
useEffectFn: typeof useEffect = (fn: () => void) => fn(),
): METADATA {
const wasMigrated =
!!metadataHolder?.meta &&
!!applyMigrations &&
Number(getMetadataValueFrom(metadataHolder, 'migration')) !== METADATA_MIGRATIONS.length;
applyMigrations &&
Number(getMetadataValueFrom(metadataHolder, 'migration', 0, undefined, true)) !== METADATA_MIGRATIONS.length;
const appMetadata = {} as METADATA;
Object.entries(metadataWithDefaultValues).forEach(([key, defaultValue]) => {
@@ -226,6 +255,7 @@ export function getMetadataFrom<METADATA extends Partial<Metadata<AppMetadataKey
metadataHolder,
key as AppMetadataKeys,
defaultValue,
prefixes,
applyMigrations,
);
});
@@ -257,11 +287,15 @@ export function getMetadataFrom<METADATA extends Partial<Metadata<AppMetadataKey
return appMetadata;
}
export const getAppMetadataFrom = (meta: Metadata, appPrefix: string = APP_METADATA_KEY_PREFIX): Metadata => {
export const getAppMetadataFrom = (
meta: Metadata,
prefixes: string[] = [],
appPrefix: string = APP_METADATA_KEY_PREFIX,
): Metadata => {
const appMetadata: Metadata = {};
Object.entries(meta).forEach(([key, value]) => {
if (key.startsWith(appPrefix)) {
if (key.startsWith([appPrefix, ...prefixes].join('_'))) {
appMetadata[key] = value;
}
});

View File

@@ -25,8 +25,9 @@ export const requestUpdateMetadataValue = async (
holderType: MetadataHolderType,
key: AppMetadataKeys,
value: AllowedMetadataValueTypes,
keyPrefixes?: string[],
): Promise<void> => {
const metadataKey = getMetadataKey(key);
const metadataKey = getMetadataKey(key, keyPrefixes);
switch (holderType) {
case 'category':
@@ -54,38 +55,50 @@ export const requestUpdateMetadata = async (
metadataHolder: GqlMetaHolder,
holderType: MetadataHolderType,
keysToValues: MetadataKeyValuePair[],
keyPrefixes?: string[],
): Promise<void[]> =>
Promise.all(keysToValues.map(([key, value]) => requestUpdateMetadataValue(metadataHolder, holderType, key, value)));
Promise.all(
keysToValues.map(([key, value]) =>
requestUpdateMetadataValue(metadataHolder, holderType, key, value, keyPrefixes),
),
);
export const requestUpdateServerMetadata = async (keysToValues: MetadataKeyValuePair[]): Promise<void[]> =>
requestUpdateMetadata({}, 'global', keysToValues);
export const requestUpdateServerMetadata = async (
keysToValues: MetadataKeyValuePair[],
keyPrefixes?: string[],
): Promise<void[]> => requestUpdateMetadata({}, 'global', keysToValues, keyPrefixes);
export const requestUpdateMangaMetadata = async (
manga: MangaIdInfo & GqlMetaHolder,
keysToValues: MetadataKeyValuePair[],
): Promise<void[]> => requestUpdateMetadata(manga, 'manga', keysToValues);
keyPrefixes?: string[],
): Promise<void[]> => requestUpdateMetadata(manga, 'manga', keysToValues, keyPrefixes);
export const requestUpdateChapterMetadata = async (
chapter: ChapterIdInfo & GqlMetaHolder,
keysToValues: MetadataKeyValuePair[],
): Promise<void[]> => requestUpdateMetadata(chapter, 'chapter', keysToValues);
keyPrefixes?: string[],
): Promise<void[]> => requestUpdateMetadata(chapter, 'chapter', keysToValues, keyPrefixes);
export const requestUpdateCategoryMetadata = async (
category: CategoryIdInfo & GqlMetaHolder,
keysToValues: MetadataKeyValuePair[],
): Promise<void[]> => requestUpdateMetadata(category, 'category', keysToValues);
keyPrefixes?: string[],
): Promise<void[]> => requestUpdateMetadata(category, 'category', keysToValues, keyPrefixes);
export const requestUpdateSourceMetadata = async (
source: Pick<SourceType, 'id'> & GqlMetaHolder,
keysToValue: MetadataKeyValuePair[],
): Promise<void[]> => requestUpdateMetadata(source, 'source', keysToValue);
keyPrefixes?: string[],
): Promise<void[]> => requestUpdateMetadata(source, 'source', keysToValue, keyPrefixes);
export const requestDeleteMetadataValue = async (
metadataHolder: GqlMetaHolder,
holderType: MetadataHolderType,
key: AppMetadataKeys,
keyPrefixes?: string[],
): Promise<void> => {
const metadataKey = getMetadataKey(key);
const metadataKey = getMetadataKey(key, keyPrefixes);
switch (holderType) {
case 'category':
@@ -112,29 +125,34 @@ export async function requestDeleteMetadata(
metadataHolder: GqlMetaHolder,
holderType: MetadataHolderType,
keys: AppMetadataKeys[],
keyPrefixes?: string[],
): Promise<void[]> {
return Promise.all(keys.map((key) => requestDeleteMetadataValue(metadataHolder, holderType, key)));
return Promise.all(keys.map((key) => requestDeleteMetadataValue(metadataHolder, holderType, key, keyPrefixes)));
}
export const requestDeleteServerMetadata = async (keys: AppMetadataKeys[]): Promise<void[]> =>
requestDeleteMetadata({}, 'global', keys);
export const requestDeleteServerMetadata = async (keys: AppMetadataKeys[], keyPrefixes?: string[]): Promise<void[]> =>
requestDeleteMetadata({}, 'global', keys, keyPrefixes);
export const requestDeleteMangaMetadata = async (
manga: MangaIdInfo & GqlMetaHolder,
keys: AppMetadataKeys[],
): Promise<void[]> => requestDeleteMetadata(manga, 'manga', keys);
keyPrefixes?: string[],
): Promise<void[]> => requestDeleteMetadata(manga, 'manga', keys, keyPrefixes);
export const requestDeleteChapterMetadata = async (
chapter: ChapterIdInfo & GqlMetaHolder,
keys: AppMetadataKeys[],
): Promise<void[]> => requestDeleteMetadata(chapter, 'chapter', keys);
keyPrefixes?: string[],
): Promise<void[]> => requestDeleteMetadata(chapter, 'chapter', keys, keyPrefixes);
export const requestDeleteCategoryMetadata = async (
category: CategoryIdInfo & GqlMetaHolder,
keys: AppMetadataKeys[],
): Promise<void[]> => requestDeleteMetadata(category, 'category', keys);
keyPrefixes?: string[],
): Promise<void[]> => requestDeleteMetadata(category, 'category', keys, keyPrefixes);
export const requestDeleteSourceMetadata = async (
source: Pick<SourceType, 'id'> & GqlMetaHolder,
keys: AppMetadataKeys[],
): Promise<void[]> => requestDeleteMetadata(source, 'source', keys);
keyPrefixes?: string[],
): Promise<void[]> => requestDeleteMetadata(source, 'source', keys, keyPrefixes);

View File

@@ -0,0 +1,35 @@
/*
* 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 ManageAccountsIcon from '@mui/icons-material/ManageAccounts';
import { IReaderSettingsWithDefaultFlag } from '@/modules/reader/types/Reader.types.ts';
import { MultiValueButtonDefaultableProps } from '@/modules/core/Core.types.ts';
import { ValueRotationButton } from '@/modules/core/components/buttons/ValueRotationButton.tsx';
import {
createProfileValueToDisplayData,
getValidReaderProfile,
} from '@/modules/reader/utils/ReaderSettings.utils.tsx';
export const ReaderNavBarDesktopProfile = ({
defaultProfile,
profiles,
updateSetting,
...buttonSelectInputProps
}: Pick<IReaderSettingsWithDefaultFlag, 'defaultProfile' | 'profiles'> &
Pick<MultiValueButtonDefaultableProps<string>, 'isDefaultable' | 'onDefault'> & {
updateSetting: (profile: string) => void;
}) => (
<ValueRotationButton
{...buttonSelectInputProps}
value={defaultProfile.isDefault ? undefined : getValidReaderProfile(defaultProfile.value, profiles)}
values={profiles}
setValue={updateSetting}
valueToDisplayData={createProfileValueToDisplayData(profiles, true)}
defaultIcon={<ManageAccountsIcon />}
/>
);

View File

@@ -16,9 +16,18 @@ import { ReaderNavBarDesktopOffsetDoubleSpread } from '@/modules/reader/componen
import { ReaderNavBarDesktopReadingDirection } from '@/modules/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopReadingDirection.tsx';
import { ReaderSettingsTypeProps } from '@/modules/reader/types/Reader.types.ts';
import { ReaderNavBarDesktopProps } from '@/modules/reader/types/ReaderOverlay.types.ts';
import { ReaderNavBarDesktopProfile } from '@/modules/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopProfile.tsx';
export const ReaderNavBarDesktopQuickSettings = ({
settings: { readingMode, shouldOffsetDoubleSpreads, pageScaleMode, shouldStretchPage, readingDirection },
settings: {
defaultProfile,
profiles,
readingMode,
shouldOffsetDoubleSpreads,
pageScaleMode,
shouldStretchPage,
readingDirection,
},
updateSetting,
openSettings,
isDefaultable,
@@ -28,6 +37,13 @@ export const ReaderNavBarDesktopQuickSettings = ({
return (
<Stack sx={{ gap: 1 }}>
<ReaderNavBarDesktopProfile
defaultProfile={defaultProfile}
profiles={profiles}
updateSetting={(value) => updateSetting('defaultProfile', value)}
isDefaultable={isDefaultable}
onDefault={() => onDefault?.('defaultProfile')}
/>
<ReaderNavBarDesktopReadingMode
readingMode={readingMode}
setReadingMode={(value) => updateSetting('readingMode', value)}

View File

@@ -12,11 +12,12 @@ import { ReaderSettingReadingDirection } from '@/modules/reader/components/setti
import { ReaderService } from '@/modules/reader/services/ReaderService.ts';
import { useReaderStateMangaContext } from '@/modules/reader/contexts/state/ReaderStateMangaContext.tsx';
import { MangaIdInfo } from '@/modules/manga/Manga.types.ts';
import { ReaderSettingProfile } from '@/modules/reader/components/settings/layout/profiles/ReaderSettingProfile.tsx';
const DEFAULT_MANGA: MangaIdInfo = { id: -1 };
export const ReaderBottomBarMobileQuickSettings = () => {
const { manga } = useReaderStateMangaContext();
const { readingMode, readingDirection } = ReaderService.useSettings();
const { defaultProfile, profiles, readingMode, readingDirection } = ReaderService.useSettings();
const deleteSetting = ReaderService.useCreateDeleteSetting(manga ?? DEFAULT_MANGA);
if (!manga) {
@@ -25,6 +26,13 @@ export const ReaderBottomBarMobileQuickSettings = () => {
return (
<Stack sx={{ gap: 2 }}>
<ReaderSettingProfile
defaultProfile={defaultProfile}
profiles={profiles}
updateSetting={(value) => ReaderService.updateSetting(manga, 'defaultProfile', value)}
isDefaultable
onDefault={() => deleteSetting('defaultProfile')}
/>
<ReaderSettingReadingMode
readingMode={readingMode}
setReadingMode={(value) => ReaderService.updateSetting(manga, 'readingMode', value)}

View File

@@ -21,6 +21,7 @@ import { ReaderLayoutSettings } from '@/modules/reader/components/settings/layou
import { ReaderGeneralSettings } from '@/modules/reader/components/settings/general/ReaderGeneralSettings.tsx';
import { ReaderFilterSettings } from '@/modules/reader/components/settings/filters/ReaderFilterSettings.tsx';
import { ReaderBehaviourSettings } from '@/modules/reader/components/settings/behaviour/ReaderBehaviourSettings.tsx';
import { ReaderDefaultLayoutSettings } from '@/modules/reader/components/settings/layout/ReaderDefaultLayoutSettings.tsx';
interface BaseProps {
activeTab: number;
@@ -91,13 +92,23 @@ export const ReaderSettingsTabs = ({
switch (id as ReaderSettingTab) {
case ReaderSettingTab.LAYOUT:
if (areDefaultSettings) {
return (
<TabPanel key={id} index={id} currentIndex={activeTab}>
<ReaderDefaultLayoutSettings
settings={settings}
updateSetting={(...args) => updateSetting(...args)}
/>
</TabPanel>
);
}
return (
<TabPanel key={id} index={id} currentIndex={activeTab}>
<ReaderLayoutSettings
settings={settings}
updateSetting={(...args) => updateSetting(...args)}
setShowPreview={setShowPreview!}
// @ts-expect-error - TS2322: Type boolean is not assignable to type true
isDefaultable={!areDefaultSettings}
onDefault={(...args) => deleteSetting?.(...args)}
/>

View File

@@ -0,0 +1,61 @@
/*
* 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 Stack from '@mui/material/Stack';
import { ComponentProps } from 'react';
import { ReaderLayoutSettings } from '@/modules/reader/components/settings/layout/ReaderLayoutSettings.tsx';
import { ReaderSettingProfiles } from '@/modules/reader/components/settings/layout/profiles/ReaderSettingProfiles.tsx';
import { ReaderSettingReadingModesDefaultProfile } from '@/modules/reader/components/settings/layout/profiles/ReaderSettingReadingModesDefaultProfile.tsx';
import { ReaderSettingProfileSettings } from '@/modules/reader/components/settings/layout/profiles/ReaderSettingProfileSettings.tsx';
import { DEFAULT_READER_PROFILE } from '@/modules/reader/constants/ReaderSettings.constants.tsx';
import { IReaderSettings } from '@/modules/reader/types/Reader.types.ts';
export const ReaderDefaultLayoutSettings = (
props: Omit<ComponentProps<typeof ReaderLayoutSettings>, 'setShowPreview'>,
) => {
const { settings, updateSetting } = props;
return (
<Stack sx={{ gap: 2, pb: 2 }}>
<Stack sx={{ gap: 2 }}>
<ReaderSettingProfiles
profiles={settings.profiles}
updateSetting={(profiles, removedProfiles) => {
if (removedProfiles.length) {
updateSetting(
'readingModesDefaultProfile',
Object.fromEntries(
Object.entries(settings.readingModesDefaultProfile).map(
([readingMode, profile]) => [
readingMode,
removedProfiles.includes(profile) ? DEFAULT_READER_PROFILE : profile,
],
),
) as IReaderSettings['readingModesDefaultProfile'],
);
}
updateSetting('profiles', profiles);
}}
/>
<Stack sx={{ px: 2 }}>
<ReaderSettingReadingModesDefaultProfile
profiles={settings.profiles}
readingModesDefaultProfile={settings.readingModesDefaultProfile}
updateSetting={(readingModesDefaultProfile) =>
updateSetting('readingModesDefaultProfile', readingModesDefaultProfile)
}
/>
</Stack>
</Stack>
{settings.profiles.map((profile) => (
<ReaderSettingProfileSettings key={profile} profile={profile} {...props} />
))}
</Stack>
);
};

View File

@@ -18,6 +18,7 @@ import { ReaderSettingStretchPage } from '@/modules/reader/components/settings/l
import { ReaderSettingsTypeProps } from '@/modules/reader/types/Reader.types.ts';
import { ReaderSettingPageGap } from '@/modules/reader/components/settings/layout/ReaderSettingPageGap.tsx';
import { ReaderSettingWidth } from '@/modules/reader/components/settings/layout/ReaderSettingWidth.tsx';
import { ReaderSettingProfile } from '@/modules/reader/components/settings/layout/profiles/ReaderSettingProfile.tsx';
export const ReaderLayoutSettings = ({
setShowPreview,
@@ -29,6 +30,15 @@ export const ReaderLayoutSettings = ({
setShowPreview: ContextType<typeof ReaderTapZoneContext>['setShowPreview'];
}) => (
<Stack sx={{ gap: 2 }}>
{isDefaultable && (
<ReaderSettingProfile
defaultProfile={settings.defaultProfile}
profiles={settings.profiles}
updateSetting={(value) => updateSetting('defaultProfile', value)}
isDefaultable={isDefaultable}
onDefault={() => onDefault?.('defaultProfile')}
/>
)}
<ReaderSettingReadingMode
readingMode={settings.readingMode}
setReadingMode={(value) => updateSetting('readingMode', value)}

View File

@@ -0,0 +1,39 @@
/*
* 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 { ButtonSelectInput } from '@/modules/core/components/inputs/ButtonSelectInput.tsx';
import { IReaderSettingsWithDefaultFlag } from '@/modules/reader/types/Reader.types.ts';
import { MultiValueButtonDefaultableProps } from '@/modules/core/Core.types.ts';
import {
createProfileValueToDisplayData,
getValidReaderProfile,
} from '@/modules/reader/utils/ReaderSettings.utils.tsx';
export const ReaderSettingProfile = ({
defaultProfile,
profiles,
updateSetting,
...buttonSelectInputProps
}: Pick<IReaderSettingsWithDefaultFlag, 'defaultProfile' | 'profiles'> &
Pick<MultiValueButtonDefaultableProps<string>, 'isDefaultable' | 'onDefault'> & {
updateSetting: (profile: string) => void;
}) => {
const { t } = useTranslation();
return (
<ButtonSelectInput
{...buttonSelectInputProps}
label={t('global.label.profile_one')}
value={defaultProfile.isDefault ? undefined : getValidReaderProfile(defaultProfile.value, profiles)}
values={profiles}
setValue={updateSetting}
valueToDisplayData={createProfileValueToDisplayData(profiles)}
/>
);
};

View File

@@ -0,0 +1,40 @@
/*
* 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 Stack from '@mui/material/Stack';
import ListSubheader from '@mui/material/ListSubheader';
import { ComponentProps } from 'react';
import { useTranslation } from 'react-i18next';
import { ReaderLayoutSettings } from '@/modules/reader/components/settings/layout/ReaderLayoutSettings.tsx';
import { useDefaultReaderSettingsWithDefaultFlag } from '@/modules/reader/services/ReaderSettingsMetadata.ts';
import { DEFAULT_READER_PROFILE } from '@/modules/reader/constants/ReaderSettings.constants.tsx';
export const ReaderSettingProfileSettings = ({
profile,
updateSetting,
...props
}: Pick<ComponentProps<typeof ReaderLayoutSettings>, 'updateSetting'> & { profile: string }) => {
const { t } = useTranslation();
const { settings } = useDefaultReaderSettingsWithDefaultFlag(profile);
return (
<Stack sx={{ gap: 2 }}>
<ListSubheader component="div" id={`${profile}-settings`}>
{profile === DEFAULT_READER_PROFILE ? t('global.label.standard') : profile}
</ListSubheader>
<Stack sx={{ px: 2 }}>
<ReaderLayoutSettings
{...props}
setShowPreview={() => {}}
settings={settings}
updateSetting={(setting, value, commit) => updateSetting(setting, value, commit, true, profile)}
/>
</Stack>
</Stack>
);
};

View File

@@ -0,0 +1,53 @@
/*
* 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 { MutableListSetting } from '@/modules/core/components/settings/MutableListSetting.tsx';
import { IReaderSettings } from '@/modules/reader/types/Reader.types.ts';
import { DEFAULT_READER_PROFILE } from '@/modules/reader/constants/ReaderSettings.constants.tsx';
export const ReaderSettingProfiles = ({
profiles,
updateSetting,
}: Pick<IReaderSettings, 'profiles'> & {
updateSetting: (profiles: IReaderSettings['profiles'], removedProfiles: string[]) => void;
}) => {
const { t } = useTranslation();
return (
<MutableListSetting
settingName={t('global.label.profile_other')}
description={t('reader.settings.profiles.description')}
handleChange={(profileList, removedProfiles) =>
updateSetting(
[
...new Set(
[DEFAULT_READER_PROFILE, ...profileList].filter(
(device) => device !== t('global.label.standard'),
),
),
],
removedProfiles,
)
}
valueInfos={profiles.map((profile) => [
profile === DEFAULT_READER_PROFILE ? t('global.label.standard') : profile,
{ mutable: false, deletable: profile !== DEFAULT_READER_PROFILE },
])}
addItemButtonTitle={t('global.button.create')}
validateItem={(profileToValidate, tmpProfiles) =>
profileToValidate.length <= 16 &&
!!profileToValidate.match(/^[a-zA-Z0-9\-_]+$/g) &&
![...profiles, ...(tmpProfiles ?? [])]
.map((profile) => profile.toLowerCase())
.includes(profileToValidate.toLowerCase())
}
placeholder={t('reader.settings.profiles.placeholder')}
/>
);
};

View File

@@ -0,0 +1,50 @@
/*
* 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 Stack from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import Typography from '@mui/material/Typography';
import {
READING_MODE_VALUE_TO_DISPLAY_DATA,
READING_MODE_VALUES,
} from '@/modules/reader/constants/ReaderSettings.constants.tsx';
import { IReaderSettings } from '@/modules/reader/types/Reader.types.ts';
import { ButtonSelectInput } from '@/modules/core/components/inputs/ButtonSelectInput.tsx';
import { createProfileValueToDisplayData } from '@/modules/reader/utils/ReaderSettings.utils.tsx';
export const ReaderSettingReadingModesDefaultProfile = ({
profiles,
readingModesDefaultProfile,
updateSetting,
}: Pick<IReaderSettings, 'profiles' | 'readingModesDefaultProfile'> & {
updateSetting: (readingModesDefaultProfile: IReaderSettings['readingModesDefaultProfile']) => void;
}) => {
const { t } = useTranslation();
return (
<Stack sx={{ gap: 2 }}>
<Typography>Reading mode default profile</Typography>
{READING_MODE_VALUES.map((readingMode) => (
<Stack key={readingMode} sx={{ gap: 1 }}>
<ButtonSelectInput
label={t(READING_MODE_VALUE_TO_DISPLAY_DATA[readingMode].title)}
value={readingModesDefaultProfile[readingMode]}
values={profiles}
setValue={(profile) =>
updateSetting({
...readingModesDefaultProfile,
[readingMode]: profile,
})
}
valueToDisplayData={createProfileValueToDisplayData(profiles)}
/>
</Stack>
))}
</Stack>
);
};

View File

@@ -33,12 +33,14 @@ import { ContinuousHorizontalPageIcon } from '@/assets/icons/svg/ContinuousHoriz
import { TranslationKey } from '@/Base.types.ts';
import { TapZoneLayouts } from '@/modules/reader/types/TapZoneLayout.types.ts';
export const DEFAULT_READER_PROFILE = 'default';
export const READING_DIRECTION_TO_THEME_DIRECTION: Record<ReadingDirection, Direction> = {
[ReadingDirection.LTR]: 'ltr',
[ReadingDirection.RTL]: 'rtl',
};
export const GLOBAL_READER_SETTING_OBJECT: Record<keyof IReaderSettingsGlobal, undefined> = {
const GLOBAL_READER_SETTING_OBJECT: Record<keyof IReaderSettingsGlobal, undefined> = {
overlayMode: undefined,
exitMode: undefined,
customFilter: undefined,
@@ -49,6 +51,8 @@ export const GLOBAL_READER_SETTING_OBJECT: Record<keyof IReaderSettingsGlobal, u
shouldShowPageNumber: undefined,
isStaticNav: undefined,
backgroundColor: undefined,
profiles: undefined,
readingModesDefaultProfile: undefined,
};
export const GLOBAL_READER_SETTING_KEYS = Object.keys(GLOBAL_READER_SETTING_OBJECT);
@@ -102,6 +106,14 @@ export const DEFAULT_READER_SETTINGS: IReaderSettings = {
invert: false,
},
pageGap: 5,
profiles: [DEFAULT_READER_PROFILE],
readingModesDefaultProfile: {
[ReadingMode.SINGLE_PAGE]: DEFAULT_READER_PROFILE,
[ReadingMode.DOUBLE_PAGE]: DEFAULT_READER_PROFILE,
[ReadingMode.CONTINUOUS_VERTICAL]: DEFAULT_READER_PROFILE,
[ReadingMode.CONTINUOUS_HORIZONTAL]: DEFAULT_READER_PROFILE,
},
defaultProfile: DEFAULT_READER_PROFILE,
};
export const READER_PROGRESS_BAR_POSITION_TO_PLACEMENT: Record<ProgressBarPosition, TooltipProps['placement']> = {
@@ -153,7 +165,7 @@ export const READER_PAGE_SCALE_MODE_TO_SCALING_ALLOWED: Record<ReaderPageScaleMo
[ReaderPageScaleMode.ORIGINAL]: false,
};
export const READING_MODE_VALUE_TO_DISPLAY_DATA: ValueToDisplayData<ReadingMode> = {
export const READING_MODE_VALUE_TO_DISPLAY_DATA = {
[ReadingMode.SINGLE_PAGE]: {
title: 'reader.settings.reader_type.label.single_page',
icon: <SinglePageIcon />,
@@ -170,7 +182,8 @@ export const READING_MODE_VALUE_TO_DISPLAY_DATA: ValueToDisplayData<ReadingMode>
title: 'reader.settings.reader_type.label.continuous_horizontal',
icon: <ContinuousHorizontalPageIcon />,
},
};
} satisfies ValueToDisplayData<ReadingMode>;
export const READING_MODE_VALUES = Object.values(ReadingMode).filter((value) => typeof value === 'number');
export enum ReaderSettingTab {

View File

@@ -10,11 +10,14 @@ import { ReactNode } from 'react';
import { ReaderStatePagesContextProvider } from '@/modules/reader/contexts/state/ReaderStatePagesContextProvider.tsx';
import { ReaderStateChaptersContextProvider } from '@/modules/reader/contexts/state/ReaderStateChaptersContextProvider.tsx';
import { ReaderStateMangaContextProvider } from '@/modules/reader/contexts/state/ReaderStateMangaContextProvider.tsx';
import { ReaderStateSettingsContextProvider } from '@/modules/reader/contexts/state/ReaderStateSettingsContextProvider.tsx';
export const ReaderStateContextProvider = ({ children }: { children: ReactNode }) => (
<ReaderStateMangaContextProvider>
<ReaderStateChaptersContextProvider>
<ReaderStatePagesContextProvider>{children}</ReaderStatePagesContextProvider>
<ReaderStateSettingsContextProvider>
<ReaderStatePagesContextProvider>{children}</ReaderStatePagesContextProvider>
</ReaderStateSettingsContextProvider>
</ReaderStateChaptersContextProvider>
</ReaderStateMangaContextProvider>
);

View File

@@ -0,0 +1,23 @@
/*
* 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 { createContext, useContext } from 'react';
import { IReaderSettingsWithDefaultFlag } from '@/modules/reader/types/Reader.types.ts';
import { DEFAULT_READER_SETTINGS_WITH_DEFAULT_FLAG } from '@/modules/reader/services/ReaderSettingsMetadata.ts';
type TReaderStateSettingsContext = {
settings: IReaderSettingsWithDefaultFlag;
setSettings: (settings: IReaderSettingsWithDefaultFlag) => void;
};
export const ReaderStateSettingsContext = createContext<TReaderStateSettingsContext>({
settings: DEFAULT_READER_SETTINGS_WITH_DEFAULT_FLAG,
setSettings: () => undefined,
});
export const useReaderStateSettingsContext = () => useContext(ReaderStateSettingsContext);

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 { ContextType, ReactNode, useMemo, useState } from 'react';
import { ReaderStateSettingsContext } from '@/modules/reader/contexts/state/ReaderStateSettingsContext.tsx';
import { DEFAULT_READER_SETTINGS_WITH_DEFAULT_FLAG } from '@/modules/reader/services/ReaderSettingsMetadata.ts';
export const ReaderStateSettingsContextProvider = ({ children }: { children: ReactNode }) => {
const [settings, setSettings] = useState<ContextType<typeof ReaderStateSettingsContext>['settings']>(
DEFAULT_READER_SETTINGS_WITH_DEFAULT_FLAG,
);
const value = useMemo(() => ({ settings, setSettings }), [settings]);
return <ReaderStateSettingsContext.Provider value={value}>{children}</ReaderStateSettingsContext.Provider>;
};

View File

@@ -42,8 +42,9 @@ export const DefaultReaderSettings = () => {
key: Setting,
value: IReaderSettings[Setting],
commit?: boolean,
profile?: string,
) => {
ReaderService.updateSetting({ id: -1 }, key, value, commit, true);
ReaderService.updateSetting({ id: -1 }, key, value, commit, true, profile);
};
if (loading) {
@@ -66,7 +67,7 @@ export const DefaultReaderSettings = () => {
setActiveTab={setActiveTab}
areDefaultSettings
settings={settings}
updateSetting={(setting, value, commit) => updateSetting(setting, value, commit)}
updateSetting={(setting, value, commit, _, profile) => updateSetting(setting, value, commit, profile)}
/>
);
};

View File

@@ -20,11 +20,7 @@ import {
ReadingDirection,
} from '@/modules/reader/types/Reader.types.ts';
import { useReaderStateMangaContext } from '@/modules/reader/contexts/state/ReaderStateMangaContext.tsx';
import {
updateReaderSettings,
useDefaultReaderSettings,
useGetReaderSettingsFor,
} from '@/modules/reader/services/ReaderSettingsMetadata.ts';
import { updateReaderSettings } from '@/modules/reader/services/ReaderSettingsMetadata.ts';
import { MangaIdInfo } from '@/modules/manga/Manga.types.ts';
import { getMetadataKey } from '@/modules/metadata/services/MetadataReader.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts';
@@ -35,6 +31,7 @@ import { GLOBAL_METADATA } from '@/lib/graphql/fragments/Fragments.ts';
import { updateMetadataList } from '@/modules/metadata/services/MetadataApolloCacheHandler.ts';
import { useBackButton } from '@/modules/core/hooks/useBackButton.ts';
import { GLOBAL_READER_SETTING_KEYS } from '@/modules/reader/constants/ReaderSettings.constants.tsx';
import { useReaderStateSettingsContext } from '@/modules/reader/contexts/state/ReaderStateSettingsContext.tsx';
const DIRECTION_TO_INVERTED: Record<Direction, Direction> = {
ltr: 'rtl',
@@ -46,8 +43,6 @@ const DIRECTION_TO_READING_DIRECTION: Record<Direction, ReadingDirection> = {
rtl: ReadingDirection.RTL,
};
const DEFAULT_MANGA: MangaIdInfo = { id: -1 };
export class ReaderService {
static useNavigateToChapter(chapter?: TChapterReader): () => void {
const navigate = useNavigate();
@@ -56,10 +51,7 @@ export class ReaderService {
}
static useSettings(): IReaderSettingsWithDefaultFlag {
const { manga } = useReaderStateMangaContext();
const defaultReaderSettings = useDefaultReaderSettings();
return useGetReaderSettingsFor(manga ?? DEFAULT_MANGA, defaultReaderSettings.settings);
return useReaderStateSettingsContext().settings;
}
static useGetThemeDirection(): Direction {
@@ -80,12 +72,12 @@ export class ReaderService {
value: IReaderSettings[Setting],
commit: boolean = true,
isGlobal: boolean = false,
profile?: string,
): void {
if (!manga) {
return;
}
const key = getMetadataKey(setting);
const key = getMetadataKey(setting, profile ? [profile] : undefined);
const { cache } = requestManager.graphQLClient.client;
@@ -130,7 +122,7 @@ export class ReaderService {
}
if (commit) {
updateReaderSettings(manga, setting, value, isGlobal).catch(() =>
updateReaderSettings(manga, setting, value, isGlobal, profile).catch(() =>
makeToast(translate('reader.settings.error.label.failed_to_save_settings'), 'error'),
);
}
@@ -140,12 +132,13 @@ export class ReaderService {
manga: MangaIdInfo,
setting: Setting,
isGlobal: boolean = false,
profile?: string,
): void {
if (!manga) {
return;
}
const key = getMetadataKey(setting);
const key = getMetadataKey(setting, profile ? [profile] : undefined);
const { cache } = requestManager.graphQLClient.client;
@@ -166,18 +159,27 @@ export class ReaderService {
static useCreateUpdateSetting<Setting extends keyof IReaderSettings>(
manga: MangaIdInfo,
profile?: string,
): (
...args: OmitFirst<Parameters<typeof this.updateSetting<Setting>>>
) => ReturnType<typeof this.updateSetting<Setting>> {
return useCallback((...args) => this.updateSetting<Setting>(manga, ...args), [manga]);
return useCallback(
(setting, value, commit, isGlobal) =>
this.updateSetting<Setting>(manga, setting, value, commit, isGlobal, profile),
[manga, profile],
);
}
static useCreateDeleteSetting<Setting extends keyof IReaderSettings>(
manga: MangaIdInfo,
profile?: string,
): (
...args: OmitFirst<Parameters<typeof this.deleteSetting<Setting>>>
) => ReturnType<typeof this.deleteSetting<Setting>> {
return useCallback((...args) => this.deleteSetting<Setting>(manga, ...args), [manga]);
return useCallback(
(setting, isGlobal) => this.deleteSetting<Setting>(manga, setting, isGlobal, profile),
[manga, profile],
);
}
static useOverlayMode(): { mode: ReaderOverlayMode; isDesktop: boolean; isMobile: boolean } {

View File

@@ -28,12 +28,21 @@ import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'
import { jsonSaveParse } from '@/lib/HelperFunctions.ts';
import { MangaIdInfo } from '@/modules/manga/Manga.types.ts';
import {
DEFAULT_READER_PROFILE,
DEFAULT_READER_SETTINGS,
GLOBAL_READER_SETTING_KEYS,
} from '@/modules/reader/constants/ReaderSettings.constants.tsx';
import { DEFAULT_DEVICE, getActiveDevice } from '@/modules/device/services/Device.ts';
import { APP_METADATA_KEY_PREFIX } from '@/modules/metadata/Metadata.constants.ts';
export const convertFromReaderSettingsWithDefaultFlag = (settings: IReaderSettingsWithDefaultFlag): IReaderSettings =>
Object.fromEntries(
Object.entries(settings).map(([key, value]) => [
key,
Object.hasOwn(value, 'value') && Object.hasOwn(value, 'isDefault') ? value.value : value,
]),
) as IReaderSettings;
const convertToSettingsWithDefaultFlag = (
type: Extract<MetadataHolderType, 'global' | 'manga'>,
settings: IReaderSettings,
@@ -82,6 +91,8 @@ const convertSettingsToMetadata = (
tapZoneInvertMode: JSON.stringify(settings.tapZoneInvertMode),
customFilter: JSON.stringify(settings.customFilter),
readerWidth: JSON.stringify(settings.readerWidth),
profiles: JSON.stringify(settings.profiles),
readingModesDefaultProfile: JSON.stringify(settings.readingModesDefaultProfile),
});
export const DEFAULT_READER_SETTINGS_WITH_DEFAULT_FLAG = convertToSettingsWithDefaultFlag(
@@ -93,30 +104,48 @@ export const DEFAULT_READER_SETTINGS_WITH_DEFAULT_FLAG = convertToSettingsWithDe
const convertMetadataToSettings = (
metadata: Partial<Metadata<AppMetadataKeys, AllowedMetadataValueTypes>>,
defaultSettings: IReaderSettings,
): IReaderSettings => ({
...(metadata as unknown as IReaderSettings),
tapZoneInvertMode:
jsonSaveParse<IReaderSettings['tapZoneInvertMode']>((metadata.tapZoneInvertMode as string) ?? '') ??
defaultSettings.tapZoneInvertMode,
customFilter:
jsonSaveParse<IReaderSettings['customFilter']>((metadata.customFilter as string) ?? '') ??
defaultSettings.customFilter,
readerWidth:
jsonSaveParse<IReaderSettings['readerWidth']>((metadata.readerWidth as string) ?? '') ??
defaultSettings.readerWidth,
});
): IReaderSettings => {
const profiles =
jsonSaveParse<IReaderSettings['profiles']>((metadata.profiles as string) ?? '') ?? defaultSettings.profiles;
const getReaderMetadata = (
return {
...(metadata as unknown as IReaderSettings),
tapZoneInvertMode:
jsonSaveParse<IReaderSettings['tapZoneInvertMode']>((metadata.tapZoneInvertMode as string) ?? '') ??
defaultSettings.tapZoneInvertMode,
customFilter:
jsonSaveParse<IReaderSettings['customFilter']>((metadata.customFilter as string) ?? '') ??
defaultSettings.customFilter,
readerWidth:
jsonSaveParse<IReaderSettings['readerWidth']>((metadata.readerWidth as string) ?? '') ??
defaultSettings.readerWidth,
profiles,
readingModesDefaultProfile: Object.fromEntries(
Object.entries(
jsonSaveParse<IReaderSettings['readingModesDefaultProfile']>(
(metadata.readingModesDefaultProfile as string) ?? '',
) ?? defaultSettings.readingModesDefaultProfile,
).map(([readingMode, profile]) => [
readingMode,
profiles.includes(profile) ? profile : DEFAULT_READER_PROFILE,
]),
) as IReaderSettings['readingModesDefaultProfile'],
};
};
export const getReaderSettings = (
type: Extract<MetadataHolderType, 'global' | 'manga'>,
metadataHolder: (MangaIdInfo & MetadataHolder) | MetadataHolder,
defaultSettings: IReaderSettings = DEFAULT_READER_SETTINGS,
useEffectFn?: typeof useEffect,
profile: string = DEFAULT_READER_PROFILE,
) =>
convertMetadataToSettings(
getMetadataFrom(
type as Parameters<typeof getMetadataFrom>[0],
metadataHolder as Parameters<typeof getMetadataFrom>[1],
convertSettingsToMetadata(defaultSettings),
[profile],
true,
useEffectFn,
),
@@ -128,20 +157,23 @@ function getReaderSettingsWithDefaultValueFallback(
metadataHolder: MetadataHolder,
defaultSettings?: IReaderSettings,
useEffectFn?: typeof useEffect,
profile?: string,
): IReaderSettingsWithDefaultFlag;
function getReaderSettingsWithDefaultValueFallback(
type: 'manga',
metadataHolder: MangaIdInfo & MetadataHolder,
defaultSettings?: IReaderSettings,
useEffectFn?: typeof useEffect,
profile?: string,
): IReaderSettingsWithDefaultFlag;
function getReaderSettingsWithDefaultValueFallback(
type: Extract<MetadataHolderType, 'global' | 'manga'>,
metadataHolder: (MangaIdInfo & MetadataHolder) | MetadataHolder,
defaultSettings: IReaderSettings = DEFAULT_READER_SETTINGS,
useEffectFn?: typeof useEffect,
profile?: string,
): IReaderSettingsWithDefaultFlag {
const settings = getReaderMetadata(type, metadataHolder, defaultSettings, useEffectFn);
const settings = getReaderSettings(type, metadataHolder, defaultSettings, useEffectFn, profile);
return convertToSettingsWithDefaultFlag(type, settings, metadataHolder);
}
@@ -149,7 +181,8 @@ const getSettings = (
metaHolder: MangaIdInfo & GqlMetaHolder,
defaultSettings?: IReaderSettings,
useEffectFn?: typeof useEffect,
) =>
profile?: string,
): IReaderSettingsWithDefaultFlag =>
getReaderSettingsWithDefaultValueFallback(
'manga',
{
@@ -161,6 +194,7 @@ const getSettings = (
},
defaultSettings,
useEffectFn,
profile,
);
export const getReaderSettingsFor = (
@@ -171,12 +205,15 @@ export const getReaderSettingsFor = (
export const useGetReaderSettingsFor = (
metaHolder: MangaIdInfo & GqlMetaHolder,
defaultSettings: IReaderSettings,
profile?: string,
): IReaderSettingsWithDefaultFlag => {
const settings = getSettings(metaHolder, defaultSettings, useEffect);
return useMemo(() => settings, [metaHolder, defaultSettings]);
const settings = getSettings(metaHolder, defaultSettings, useEffect, profile);
return useMemo(() => settings, [metaHolder, defaultSettings, profile]);
};
export const useDefaultReaderSettings = (): {
export const useDefaultReaderSettings = (
profile?: string,
): {
metadata?: Metadata;
settings: IReaderSettings;
loading: boolean;
@@ -186,8 +223,8 @@ export const useDefaultReaderSettings = (): {
const { data, loading } = request;
const metadata = useMemo(() => convertFromGqlMeta(data?.metas.nodes), [data?.metas.nodes]);
const metaHolder: MetadataHolder = useMemo(() => ({ meta: metadata }), [metadata]);
const tmpSettings = getReaderMetadata('global', metaHolder, undefined, useEffect);
const settings = useMemo(() => tmpSettings, [metaHolder]);
const tmpSettings = getReaderSettings('global', metaHolder, undefined, useEffect, profile);
const settings = useMemo(() => tmpSettings, [metaHolder, profile]);
return useMemo(
() => ({
@@ -200,7 +237,9 @@ export const useDefaultReaderSettings = (): {
);
};
export const useDefaultReaderSettingsWithDefaultFlag = (): {
export const useDefaultReaderSettingsWithDefaultFlag = (
profile?: string,
): {
metadata?: Metadata;
settings: IReaderSettingsWithDefaultFlag;
loading: boolean;
@@ -210,8 +249,8 @@ export const useDefaultReaderSettingsWithDefaultFlag = (): {
const { data, loading } = request;
const metadata = useMemo(() => convertFromGqlMeta(data?.metas.nodes), [data?.metas.nodes]);
const metaHolder: MetadataHolder = useMemo(() => ({ meta: metadata }), [metadata]);
const tmpSettings = getReaderSettingsWithDefaultValueFallback('global', metaHolder, undefined, useEffect);
const settings = useMemo(() => tmpSettings, [metaHolder]);
const tmpSettings = getReaderSettingsWithDefaultValueFallback('global', metaHolder, undefined, useEffect, profile);
const settings = useMemo(() => tmpSettings, [metaHolder, profile]);
return useMemo(
() => ({
@@ -229,18 +268,27 @@ export const updateReaderSettings = async <Setting extends keyof IReaderSettings
setting: Setting,
value: IReaderSettings[Setting],
isGlobal: boolean = false,
profile?: string,
): Promise<void[]> => {
const isGlobalSetting = isGlobal || GLOBAL_READER_SETTING_KEYS.includes(setting);
if (isGlobalSetting) {
return requestUpdateServerMetadata([[setting, convertSettingsToMetadata({ [setting]: value })[setting]]]);
return requestUpdateServerMetadata(
[[setting, convertSettingsToMetadata({ [setting]: value })[setting]]],
profile ? [profile] : undefined,
);
}
return requestUpdateMangaMetadata(manga, [[setting, convertSettingsToMetadata({ [setting]: value })[setting]]]);
return requestUpdateMangaMetadata(
manga,
[[setting, convertSettingsToMetadata({ [setting]: value })[setting]]],
profile ? [profile] : undefined,
);
};
export const createUpdateReaderSettings =
<Settings extends keyof IReaderSettings>(
manga: Pick<MangaType, 'id'> & GqlMetaHolder,
handleError: (error: any) => void = defaultPromiseErrorHandler('createUpdateReaderSettings'),
profile?: string,
): ((...args: OmitFirst<Parameters<typeof updateReaderSettings<Settings>>>) => Promise<void | void[]>) =>
(setting, value, isGlobal) =>
updateReaderSettings(manga, setting, value, isGlobal).catch(handleError);
updateReaderSettings(manga, setting, value, isGlobal, profile).catch(handleError);

View File

@@ -117,6 +117,8 @@ export interface IReaderSettingsGlobal {
shouldShowPageNumber: boolean;
isStaticNav: boolean;
backgroundColor: ReaderBackgroundColor;
profiles: string[];
readingModesDefaultProfile: Record<ReadingMode, string>;
}
export interface IReaderSettingsManga {
@@ -138,6 +140,7 @@ export interface IReaderSettingsManga {
value: number;
enabled: boolean;
};
defaultProfile: string;
}
export interface IReaderSettings extends IReaderSettingsGlobal, IReaderSettingsManga {}

View File

@@ -6,7 +6,28 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import ManageAccountsIcon from '@mui/icons-material/ManageAccounts';
import { IReaderSettings, ReadingMode } from '@/modules/reader/types/Reader.types.ts';
import { DEFAULT_READER_PROFILE } from '@/modules/reader/constants/ReaderSettings.constants.tsx';
import { ValueToDisplayData } from '@/modules/core/Core.types.ts';
export const isOffsetDoubleSpreadPagesEditable = (readingMode: IReaderSettings['readingMode']): boolean =>
readingMode === ReadingMode.DOUBLE_PAGE;
export const createProfileValueToDisplayData = (
profiles: string[],
setIcon: boolean = false,
): ValueToDisplayData<string> =>
Object.fromEntries(
profiles.map((profile) => [
profile,
{
isTitleString: profile !== DEFAULT_READER_PROFILE,
title: profile === DEFAULT_READER_PROFILE ? 'global.label.standard' : profile,
icon: setIcon ? <ManageAccountsIcon /> : null,
},
]),
) as ValueToDisplayData<string>;
export const getValidReaderProfile = (profile: string, profiles: string[]): string =>
profiles.includes(profile) ? profile : DEFAULT_READER_PROFILE;

View File

@@ -51,8 +51,9 @@ const getMetadataServerSettingsWithDefaultFallback = (
useEffectFn?: typeof useEffect,
): MetadataServerSettings =>
convertMetadataToSettings(
getMetadataFrom('global', { meta }, convertSettingsToMetadata(defaultSettings), true, useEffectFn),
getMetadataFrom('global', { meta }, convertSettingsToMetadata(defaultSettings), undefined, true, useEffectFn),
);
export const useMetadataServerSettings = (): {
metadata?: Metadata;
settings: MetadataServerSettings;

View File

@@ -48,6 +48,7 @@ const getMetadata = (
'source',
{ ...metaHolder, meta: convertFromGqlMeta(metaHolder.meta) },
convertAppMetadataToGqlMetadata(DEFAULT_SOURCE_METADATA),
undefined,
true,
useEffectFn,
),