From 0ae9d9ebb20694e00c36da9eb47d004fd2cb4bd8 Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Mon, 28 Oct 2024 18:03:29 +0100 Subject: [PATCH] Custom reader profiles --- public/locales/en.json | 6 ++ .../category/services/CategoryMetadata.ts | 9 +- src/modules/core/Core.types.ts | 22 ++-- .../core/components/buttons/ButtonSelect.tsx | 4 +- .../buttons/ValueRotationButton.tsx | 4 +- .../settings/MutableListSetting.tsx | 14 ++- .../settings/text/TextSettingDialog.tsx | 6 +- src/modules/metadata/Metadata.constants.ts | 6 +- .../metadata/services/MetadataMigrations.ts | 10 +- .../metadata/services/MetadataReader.ts | 64 ++++++++--- .../metadata/services/MetadataUpdater.ts | 50 ++++++--- .../ReaderNavBarDesktopProfile.tsx | 35 ++++++ .../ReaderNavBarDesktopQuickSettings.tsx | 18 +++- .../ReaderBottomBarMobileQuickSettings.tsx | 10 +- .../settings/ReaderSettingsTabs.tsx | 13 ++- .../layout/ReaderDefaultLayoutSettings.tsx | 61 +++++++++++ .../settings/layout/ReaderLayoutSettings.tsx | 10 ++ .../layout/profiles/ReaderSettingProfile.tsx | 39 +++++++ .../profiles/ReaderSettingProfileSettings.tsx | 40 +++++++ .../layout/profiles/ReaderSettingProfiles.tsx | 53 ++++++++++ ...eaderSettingReadingModesDefaultProfile.tsx | 50 +++++++++ .../constants/ReaderSettings.constants.tsx | 19 +++- .../state/ReaderStateContextProvider.tsx | 5 +- .../state/ReaderStateSettingsContext.tsx | 23 ++++ .../ReaderStateSettingsContextProvider.tsx | 21 ++++ .../reader/screens/DefaultReaderSettings.tsx | 5 +- src/modules/reader/services/ReaderService.ts | 36 ++++--- .../reader/services/ReaderSettingsMetadata.ts | 100 +++++++++++++----- src/modules/reader/types/Reader.types.ts | 3 + .../reader/utils/ReaderSettings.utils.tsx | 21 ++++ .../services/ServerSettingsMetadata.ts | 3 +- src/modules/source/services/SourceMetadata.ts | 1 + 32 files changed, 655 insertions(+), 106 deletions(-) create mode 100644 src/modules/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopProfile.tsx create mode 100644 src/modules/reader/components/settings/layout/ReaderDefaultLayoutSettings.tsx create mode 100644 src/modules/reader/components/settings/layout/profiles/ReaderSettingProfile.tsx create mode 100644 src/modules/reader/components/settings/layout/profiles/ReaderSettingProfileSettings.tsx create mode 100644 src/modules/reader/components/settings/layout/profiles/ReaderSettingProfiles.tsx create mode 100644 src/modules/reader/components/settings/layout/profiles/ReaderSettingReadingModesDefaultProfile.tsx create mode 100644 src/modules/reader/contexts/state/ReaderStateSettingsContext.tsx create mode 100644 src/modules/reader/contexts/state/ReaderStateSettingsContextProvider.tsx diff --git a/public/locales/en.json b/public/locales/en.json index 67280fb5..52ebcd47 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -426,6 +426,8 @@ "password": "Password", "placeholder": "Placeholder", "previous": "Previous", + "profile_one": "Profile", + "profile_other": "Profiles", "right": "Right", "share": "Share", "sort": "Sort", @@ -862,6 +864,10 @@ "title": "Scale type", "width": "Fit width" }, + "profiles": { + "description": "Profiles are used to define the default reader settings which then can be used for series and reading modes", + "placeholder": "Comics | length: 16, chars: letters, numbers, -, _" + }, "progress_bar": { "position": "Progress bar position", "size": "Progress bar size", diff --git a/src/modules/category/services/CategoryMetadata.ts b/src/modules/category/services/CategoryMetadata.ts index 9c54ee2a..695d621d 100644 --- a/src/modules/category/services/CategoryMetadata.ts +++ b/src/modules/category/services/CategoryMetadata.ts @@ -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 = ( diff --git a/src/modules/core/Core.types.ts b/src/modules/core/Core.types.ts index 01c1baac..7068f233 100644 --- a/src/modules/core/Core.types.ts +++ b/src/modules/core/Core.types.ts @@ -15,13 +15,21 @@ export enum GridLayout { List = 2, } -export type ValueToDisplayData = 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 = Record; export interface MultiValueButtonBaseProps { value: Value; diff --git a/src/modules/core/components/buttons/ButtonSelect.tsx b/src/modules/core/components/buttons/ButtonSelect.tsx index 92cb46ef..aa71c4b4 100644 --- a/src/modules/core/components/buttons/ButtonSelect.tsx +++ b/src/modules/core/components/buttons/ButtonSelect.tsx @@ -35,7 +35,9 @@ export const ButtonSelect = ({ variant={displayValue === value ? 'contained' : 'outlined'} startIcon={valueToDisplayData[displayValue].icon} > - {t(valueToDisplayData[displayValue].title)} + {valueToDisplayData[displayValue].isTitleString + ? valueToDisplayData[displayValue].title + : t(valueToDisplayData[displayValue].title)} ))} diff --git a/src/modules/core/components/buttons/ValueRotationButton.tsx b/src/modules/core/components/buttons/ValueRotationButton.tsx index d4a0c457..c34c5a19 100644 --- a/src/modules/core/components/buttons/ValueRotationButton.tsx +++ b/src/modules/core/components/buttons/ValueRotationButton.tsx @@ -64,7 +64,9 @@ export const ValueRotationButton = ({ startIcon={valueToDisplayData[value].icon} size="large" > - {t(valueToDisplayData[value].title)} + {valueToDisplayData[value].isTitleString + ? valueToDisplayData[value].title + : t(valueToDisplayData[value].title)} ); }; diff --git a/src/modules/core/components/settings/MutableListSetting.tsx b/src/modules/core/components/settings/MutableListSetting.tsx index 54b538ce..1558933e 100644 --- a/src/modules/core/components/settings/MutableListSetting.tsx +++ b/src/modules/core/components/settings/MutableListSetting.tsx @@ -66,9 +66,9 @@ type MutableListSettingProps = Pick 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 = ({ {dialogValues.map((dialogValue, index) => ( 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} /> )} diff --git a/src/modules/core/components/settings/text/TextSettingDialog.tsx b/src/modules/core/components/settings/text/TextSettingDialog.tsx index 9640f24c..681a76e0 100644 --- a/src/modules/core/components/settings/text/TextSettingDialog.tsx +++ b/src/modules/core/components/settings/text/TextSettingDialog.tsx @@ -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; diff --git a/src/modules/metadata/Metadata.constants.ts b/src/modules/metadata/Metadata.constants.ts index 6d724838..cebcc297 100644 --- a/src/modules/metadata/Metadata.constants.ts +++ b/src/modules/metadata/Metadata.constants.ts @@ -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 = { migration: undefined, @@ -66,6 +66,9 @@ const APP_METADATA_OBJECT: Record = { 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', ]; /** diff --git a/src/modules/metadata/services/MetadataMigrations.ts b/src/modules/metadata/services/MetadataMigrations.ts index fc68ae47..0159e0f3 100644 --- a/src/modules/metadata/services/MetadataMigrations.ts +++ b/src/modules/metadata/services/MetadataMigrations.ts @@ -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; diff --git a/src/modules/metadata/services/MetadataReader.ts b/src/modules/metadata/services/MetadataReader.ts index 4d909673..ea7fedae 100644 --- a/src/modules/metadata/services/MetadataReader.ts +++ b/src/modules/metadata/services/MetadataReader.ts @@ -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 \_ + * + * e.g.: \_[device_name]\_[optional_prefix1]\_[optional_prefix2]\_ + */ +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 = ( { 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 = & MetadataHolder, metadataWithDefaultValues: METADATA, + prefixes?: string[], applyMigrations?: boolean, useEffectFn?: typeof useEffect, ): METADATA; @@ -212,13 +240,14 @@ export function getMetadataFrom & 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 { +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; } }); diff --git a/src/modules/metadata/services/MetadataUpdater.ts b/src/modules/metadata/services/MetadataUpdater.ts index 8d0fc5a6..0f59febd 100644 --- a/src/modules/metadata/services/MetadataUpdater.ts +++ b/src/modules/metadata/services/MetadataUpdater.ts @@ -25,8 +25,9 @@ export const requestUpdateMetadataValue = async ( holderType: MetadataHolderType, key: AppMetadataKeys, value: AllowedMetadataValueTypes, + keyPrefixes?: string[], ): Promise => { - 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 => - 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 => - requestUpdateMetadata({}, 'global', keysToValues); +export const requestUpdateServerMetadata = async ( + keysToValues: MetadataKeyValuePair[], + keyPrefixes?: string[], +): Promise => requestUpdateMetadata({}, 'global', keysToValues, keyPrefixes); export const requestUpdateMangaMetadata = async ( manga: MangaIdInfo & GqlMetaHolder, keysToValues: MetadataKeyValuePair[], -): Promise => requestUpdateMetadata(manga, 'manga', keysToValues); + keyPrefixes?: string[], +): Promise => requestUpdateMetadata(manga, 'manga', keysToValues, keyPrefixes); export const requestUpdateChapterMetadata = async ( chapter: ChapterIdInfo & GqlMetaHolder, keysToValues: MetadataKeyValuePair[], -): Promise => requestUpdateMetadata(chapter, 'chapter', keysToValues); + keyPrefixes?: string[], +): Promise => requestUpdateMetadata(chapter, 'chapter', keysToValues, keyPrefixes); export const requestUpdateCategoryMetadata = async ( category: CategoryIdInfo & GqlMetaHolder, keysToValues: MetadataKeyValuePair[], -): Promise => requestUpdateMetadata(category, 'category', keysToValues); + keyPrefixes?: string[], +): Promise => requestUpdateMetadata(category, 'category', keysToValues, keyPrefixes); export const requestUpdateSourceMetadata = async ( source: Pick & GqlMetaHolder, keysToValue: MetadataKeyValuePair[], -): Promise => requestUpdateMetadata(source, 'source', keysToValue); + keyPrefixes?: string[], +): Promise => requestUpdateMetadata(source, 'source', keysToValue, keyPrefixes); export const requestDeleteMetadataValue = async ( metadataHolder: GqlMetaHolder, holderType: MetadataHolderType, key: AppMetadataKeys, + keyPrefixes?: string[], ): Promise => { - 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 { - 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 => - requestDeleteMetadata({}, 'global', keys); +export const requestDeleteServerMetadata = async (keys: AppMetadataKeys[], keyPrefixes?: string[]): Promise => + requestDeleteMetadata({}, 'global', keys, keyPrefixes); export const requestDeleteMangaMetadata = async ( manga: MangaIdInfo & GqlMetaHolder, keys: AppMetadataKeys[], -): Promise => requestDeleteMetadata(manga, 'manga', keys); + keyPrefixes?: string[], +): Promise => requestDeleteMetadata(manga, 'manga', keys, keyPrefixes); export const requestDeleteChapterMetadata = async ( chapter: ChapterIdInfo & GqlMetaHolder, keys: AppMetadataKeys[], -): Promise => requestDeleteMetadata(chapter, 'chapter', keys); + keyPrefixes?: string[], +): Promise => requestDeleteMetadata(chapter, 'chapter', keys, keyPrefixes); export const requestDeleteCategoryMetadata = async ( category: CategoryIdInfo & GqlMetaHolder, keys: AppMetadataKeys[], -): Promise => requestDeleteMetadata(category, 'category', keys); + keyPrefixes?: string[], +): Promise => requestDeleteMetadata(category, 'category', keys, keyPrefixes); export const requestDeleteSourceMetadata = async ( source: Pick & GqlMetaHolder, keys: AppMetadataKeys[], -): Promise => requestDeleteMetadata(source, 'source', keys); + keyPrefixes?: string[], +): Promise => requestDeleteMetadata(source, 'source', keys, keyPrefixes); diff --git a/src/modules/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopProfile.tsx b/src/modules/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopProfile.tsx new file mode 100644 index 00000000..48e03e7e --- /dev/null +++ b/src/modules/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopProfile.tsx @@ -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 & + Pick, 'isDefaultable' | 'onDefault'> & { + updateSetting: (profile: string) => void; + }) => ( + } + /> +); diff --git a/src/modules/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopQuickSettings.tsx b/src/modules/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopQuickSettings.tsx index 33f96656..95c72f79 100644 --- a/src/modules/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopQuickSettings.tsx +++ b/src/modules/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopQuickSettings.tsx @@ -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 ( + updateSetting('defaultProfile', value)} + isDefaultable={isDefaultable} + onDefault={() => onDefault?.('defaultProfile')} + /> updateSetting('readingMode', value)} diff --git a/src/modules/reader/components/overlay/navigation/mobile/ReaderBottomBarMobileQuickSettings.tsx b/src/modules/reader/components/overlay/navigation/mobile/ReaderBottomBarMobileQuickSettings.tsx index 05677c86..173b3ad6 100644 --- a/src/modules/reader/components/overlay/navigation/mobile/ReaderBottomBarMobileQuickSettings.tsx +++ b/src/modules/reader/components/overlay/navigation/mobile/ReaderBottomBarMobileQuickSettings.tsx @@ -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 ( + ReaderService.updateSetting(manga, 'defaultProfile', value)} + isDefaultable + onDefault={() => deleteSetting('defaultProfile')} + /> ReaderService.updateSetting(manga, 'readingMode', value)} diff --git a/src/modules/reader/components/settings/ReaderSettingsTabs.tsx b/src/modules/reader/components/settings/ReaderSettingsTabs.tsx index c1bd1e7a..ef57a14b 100644 --- a/src/modules/reader/components/settings/ReaderSettingsTabs.tsx +++ b/src/modules/reader/components/settings/ReaderSettingsTabs.tsx @@ -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 ( + + updateSetting(...args)} + /> + + ); + } + return ( updateSetting(...args)} setShowPreview={setShowPreview!} - // @ts-expect-error - TS2322: Type boolean is not assignable to type true isDefaultable={!areDefaultSettings} onDefault={(...args) => deleteSetting?.(...args)} /> diff --git a/src/modules/reader/components/settings/layout/ReaderDefaultLayoutSettings.tsx b/src/modules/reader/components/settings/layout/ReaderDefaultLayoutSettings.tsx new file mode 100644 index 00000000..b488529f --- /dev/null +++ b/src/modules/reader/components/settings/layout/ReaderDefaultLayoutSettings.tsx @@ -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, 'setShowPreview'>, +) => { + const { settings, updateSetting } = props; + + return ( + + + { + 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); + }} + /> + + + updateSetting('readingModesDefaultProfile', readingModesDefaultProfile) + } + /> + + + {settings.profiles.map((profile) => ( + + ))} + + ); +}; diff --git a/src/modules/reader/components/settings/layout/ReaderLayoutSettings.tsx b/src/modules/reader/components/settings/layout/ReaderLayoutSettings.tsx index 09720857..0c03a522 100644 --- a/src/modules/reader/components/settings/layout/ReaderLayoutSettings.tsx +++ b/src/modules/reader/components/settings/layout/ReaderLayoutSettings.tsx @@ -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['setShowPreview']; }) => ( + {isDefaultable && ( + updateSetting('defaultProfile', value)} + isDefaultable={isDefaultable} + onDefault={() => onDefault?.('defaultProfile')} + /> + )} updateSetting('readingMode', value)} diff --git a/src/modules/reader/components/settings/layout/profiles/ReaderSettingProfile.tsx b/src/modules/reader/components/settings/layout/profiles/ReaderSettingProfile.tsx new file mode 100644 index 00000000..6f92fe3d --- /dev/null +++ b/src/modules/reader/components/settings/layout/profiles/ReaderSettingProfile.tsx @@ -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 & + Pick, 'isDefaultable' | 'onDefault'> & { + updateSetting: (profile: string) => void; + }) => { + const { t } = useTranslation(); + + return ( + + ); +}; diff --git a/src/modules/reader/components/settings/layout/profiles/ReaderSettingProfileSettings.tsx b/src/modules/reader/components/settings/layout/profiles/ReaderSettingProfileSettings.tsx new file mode 100644 index 00000000..0b68ed16 --- /dev/null +++ b/src/modules/reader/components/settings/layout/profiles/ReaderSettingProfileSettings.tsx @@ -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, 'updateSetting'> & { profile: string }) => { + const { t } = useTranslation(); + const { settings } = useDefaultReaderSettingsWithDefaultFlag(profile); + + return ( + + + {profile === DEFAULT_READER_PROFILE ? t('global.label.standard') : profile} + + + {}} + settings={settings} + updateSetting={(setting, value, commit) => updateSetting(setting, value, commit, true, profile)} + /> + + + ); +}; diff --git a/src/modules/reader/components/settings/layout/profiles/ReaderSettingProfiles.tsx b/src/modules/reader/components/settings/layout/profiles/ReaderSettingProfiles.tsx new file mode 100644 index 00000000..217d1deb --- /dev/null +++ b/src/modules/reader/components/settings/layout/profiles/ReaderSettingProfiles.tsx @@ -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 & { + updateSetting: (profiles: IReaderSettings['profiles'], removedProfiles: string[]) => void; +}) => { + const { t } = useTranslation(); + + return ( + + 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')} + /> + ); +}; diff --git a/src/modules/reader/components/settings/layout/profiles/ReaderSettingReadingModesDefaultProfile.tsx b/src/modules/reader/components/settings/layout/profiles/ReaderSettingReadingModesDefaultProfile.tsx new file mode 100644 index 00000000..c68e1791 --- /dev/null +++ b/src/modules/reader/components/settings/layout/profiles/ReaderSettingReadingModesDefaultProfile.tsx @@ -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 & { + updateSetting: (readingModesDefaultProfile: IReaderSettings['readingModesDefaultProfile']) => void; +}) => { + const { t } = useTranslation(); + + return ( + + Reading mode default profile + {READING_MODE_VALUES.map((readingMode) => ( + + + updateSetting({ + ...readingModesDefaultProfile, + [readingMode]: profile, + }) + } + valueToDisplayData={createProfileValueToDisplayData(profiles)} + /> + + ))} + + ); +}; diff --git a/src/modules/reader/constants/ReaderSettings.constants.tsx b/src/modules/reader/constants/ReaderSettings.constants.tsx index d66ea572..4b88c8cc 100644 --- a/src/modules/reader/constants/ReaderSettings.constants.tsx +++ b/src/modules/reader/constants/ReaderSettings.constants.tsx @@ -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.LTR]: 'ltr', [ReadingDirection.RTL]: 'rtl', }; -export const GLOBAL_READER_SETTING_OBJECT: Record = { +const GLOBAL_READER_SETTING_OBJECT: Record = { overlayMode: undefined, exitMode: undefined, customFilter: undefined, @@ -49,6 +51,8 @@ export const GLOBAL_READER_SETTING_OBJECT: Record = { @@ -153,7 +165,7 @@ export const READER_PAGE_SCALE_MODE_TO_SCALING_ALLOWED: Record = { +export const READING_MODE_VALUE_TO_DISPLAY_DATA = { [ReadingMode.SINGLE_PAGE]: { title: 'reader.settings.reader_type.label.single_page', icon: , @@ -170,7 +182,8 @@ export const READING_MODE_VALUE_TO_DISPLAY_DATA: ValueToDisplayData title: 'reader.settings.reader_type.label.continuous_horizontal', icon: , }, -}; +} satisfies ValueToDisplayData; + export const READING_MODE_VALUES = Object.values(ReadingMode).filter((value) => typeof value === 'number'); export enum ReaderSettingTab { diff --git a/src/modules/reader/contexts/state/ReaderStateContextProvider.tsx b/src/modules/reader/contexts/state/ReaderStateContextProvider.tsx index 12afc483..d138b97e 100644 --- a/src/modules/reader/contexts/state/ReaderStateContextProvider.tsx +++ b/src/modules/reader/contexts/state/ReaderStateContextProvider.tsx @@ -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 }) => ( - {children} + + {children} + ); diff --git a/src/modules/reader/contexts/state/ReaderStateSettingsContext.tsx b/src/modules/reader/contexts/state/ReaderStateSettingsContext.tsx new file mode 100644 index 00000000..4a5fa922 --- /dev/null +++ b/src/modules/reader/contexts/state/ReaderStateSettingsContext.tsx @@ -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({ + settings: DEFAULT_READER_SETTINGS_WITH_DEFAULT_FLAG, + setSettings: () => undefined, +}); + +export const useReaderStateSettingsContext = () => useContext(ReaderStateSettingsContext); diff --git a/src/modules/reader/contexts/state/ReaderStateSettingsContextProvider.tsx b/src/modules/reader/contexts/state/ReaderStateSettingsContextProvider.tsx new file mode 100644 index 00000000..9d771b45 --- /dev/null +++ b/src/modules/reader/contexts/state/ReaderStateSettingsContextProvider.tsx @@ -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['settings']>( + DEFAULT_READER_SETTINGS_WITH_DEFAULT_FLAG, + ); + + const value = useMemo(() => ({ settings, setSettings }), [settings]); + + return {children}; +}; diff --git a/src/modules/reader/screens/DefaultReaderSettings.tsx b/src/modules/reader/screens/DefaultReaderSettings.tsx index 6f0097b3..d043afbd 100644 --- a/src/modules/reader/screens/DefaultReaderSettings.tsx +++ b/src/modules/reader/screens/DefaultReaderSettings.tsx @@ -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)} /> ); }; diff --git a/src/modules/reader/services/ReaderService.ts b/src/modules/reader/services/ReaderService.ts index 51f62a01..70ffec0f 100644 --- a/src/modules/reader/services/ReaderService.ts +++ b/src/modules/reader/services/ReaderService.ts @@ -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 = { ltr: 'rtl', @@ -46,8 +43,6 @@ const DIRECTION_TO_READING_DIRECTION: Record = { 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( manga: MangaIdInfo, + profile?: string, ): ( ...args: OmitFirst>> ) => ReturnType> { - return useCallback((...args) => this.updateSetting(manga, ...args), [manga]); + return useCallback( + (setting, value, commit, isGlobal) => + this.updateSetting(manga, setting, value, commit, isGlobal, profile), + [manga, profile], + ); } static useCreateDeleteSetting( manga: MangaIdInfo, + profile?: string, ): ( ...args: OmitFirst>> ) => ReturnType> { - return useCallback((...args) => this.deleteSetting(manga, ...args), [manga]); + return useCallback( + (setting, isGlobal) => this.deleteSetting(manga, setting, isGlobal, profile), + [manga, profile], + ); } static useOverlayMode(): { mode: ReaderOverlayMode; isDesktop: boolean; isMobile: boolean } { diff --git a/src/modules/reader/services/ReaderSettingsMetadata.ts b/src/modules/reader/services/ReaderSettingsMetadata.ts index 9d6d7723..441599fc 100644 --- a/src/modules/reader/services/ReaderSettingsMetadata.ts +++ b/src/modules/reader/services/ReaderSettingsMetadata.ts @@ -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, 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>, defaultSettings: IReaderSettings, -): IReaderSettings => ({ - ...(metadata as unknown as IReaderSettings), - tapZoneInvertMode: - jsonSaveParse((metadata.tapZoneInvertMode as string) ?? '') ?? - defaultSettings.tapZoneInvertMode, - customFilter: - jsonSaveParse((metadata.customFilter as string) ?? '') ?? - defaultSettings.customFilter, - readerWidth: - jsonSaveParse((metadata.readerWidth as string) ?? '') ?? - defaultSettings.readerWidth, -}); +): IReaderSettings => { + const profiles = + jsonSaveParse((metadata.profiles as string) ?? '') ?? defaultSettings.profiles; -const getReaderMetadata = ( + return { + ...(metadata as unknown as IReaderSettings), + tapZoneInvertMode: + jsonSaveParse((metadata.tapZoneInvertMode as string) ?? '') ?? + defaultSettings.tapZoneInvertMode, + customFilter: + jsonSaveParse((metadata.customFilter as string) ?? '') ?? + defaultSettings.customFilter, + readerWidth: + jsonSaveParse((metadata.readerWidth as string) ?? '') ?? + defaultSettings.readerWidth, + profiles, + readingModesDefaultProfile: Object.fromEntries( + Object.entries( + jsonSaveParse( + (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, metadataHolder: (MangaIdInfo & MetadataHolder) | MetadataHolder, defaultSettings: IReaderSettings = DEFAULT_READER_SETTINGS, useEffectFn?: typeof useEffect, + profile: string = DEFAULT_READER_PROFILE, ) => convertMetadataToSettings( getMetadataFrom( type as Parameters[0], metadataHolder as Parameters[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, 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 => { 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 = ( manga: Pick & GqlMetaHolder, handleError: (error: any) => void = defaultPromiseErrorHandler('createUpdateReaderSettings'), + profile?: string, ): ((...args: OmitFirst>>) => Promise) => (setting, value, isGlobal) => - updateReaderSettings(manga, setting, value, isGlobal).catch(handleError); + updateReaderSettings(manga, setting, value, isGlobal, profile).catch(handleError); diff --git a/src/modules/reader/types/Reader.types.ts b/src/modules/reader/types/Reader.types.ts index 7d72d611..41a6a38b 100644 --- a/src/modules/reader/types/Reader.types.ts +++ b/src/modules/reader/types/Reader.types.ts @@ -117,6 +117,8 @@ export interface IReaderSettingsGlobal { shouldShowPageNumber: boolean; isStaticNav: boolean; backgroundColor: ReaderBackgroundColor; + profiles: string[]; + readingModesDefaultProfile: Record; } export interface IReaderSettingsManga { @@ -138,6 +140,7 @@ export interface IReaderSettingsManga { value: number; enabled: boolean; }; + defaultProfile: string; } export interface IReaderSettings extends IReaderSettingsGlobal, IReaderSettingsManga {} diff --git a/src/modules/reader/utils/ReaderSettings.utils.tsx b/src/modules/reader/utils/ReaderSettings.utils.tsx index cd62b46d..de741d33 100644 --- a/src/modules/reader/utils/ReaderSettings.utils.tsx +++ b/src/modules/reader/utils/ReaderSettings.utils.tsx @@ -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 => + Object.fromEntries( + profiles.map((profile) => [ + profile, + { + isTitleString: profile !== DEFAULT_READER_PROFILE, + title: profile === DEFAULT_READER_PROFILE ? 'global.label.standard' : profile, + icon: setIcon ? : null, + }, + ]), + ) as ValueToDisplayData; + +export const getValidReaderProfile = (profile: string, profiles: string[]): string => + profiles.includes(profile) ? profile : DEFAULT_READER_PROFILE; diff --git a/src/modules/settings/services/ServerSettingsMetadata.ts b/src/modules/settings/services/ServerSettingsMetadata.ts index 56ca9c52..5a9ab4c8 100644 --- a/src/modules/settings/services/ServerSettingsMetadata.ts +++ b/src/modules/settings/services/ServerSettingsMetadata.ts @@ -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; diff --git a/src/modules/source/services/SourceMetadata.ts b/src/modules/source/services/SourceMetadata.ts index 8e074372..2fb3b399 100644 --- a/src/modules/source/services/SourceMetadata.ts +++ b/src/modules/source/services/SourceMetadata.ts @@ -48,6 +48,7 @@ const getMetadata = ( 'source', { ...metaHolder, meta: convertFromGqlMeta(metaHolder.meta) }, convertAppMetadataToGqlMetadata(DEFAULT_SOURCE_METADATA), + undefined, true, useEffectFn, ),