Add option to save dynamic manga color theme
This commit is contained in:
@@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased] (Preview)
|
||||
|
||||
### Added
|
||||
- (**Theme**) Add an option to save the dynamic color theme on the manga page as a custom theme
|
||||
|
||||
### Changed
|
||||
- (**Theme**) Improve automatic theme background colors
|
||||
- (**Theme**) Adjust "primary color" of dynamic color theme on manga pages
|
||||
|
||||
@@ -1156,6 +1156,8 @@
|
||||
"appearance": {
|
||||
"manga_dynamic_color_schemes": {
|
||||
"description": "Changes the theme colors on the manga page based on the thumbnail",
|
||||
"save": "Save color theme",
|
||||
"set_active": "Set as active theme",
|
||||
"title": "Dynamic theme colors on manga page"
|
||||
},
|
||||
"manga_thumbnail_backdrop": {
|
||||
|
||||
@@ -21,10 +21,17 @@ import SyncAltIcon from '@mui/icons-material/SyncAlt';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { AwaitableComponent } from 'awaitable-component';
|
||||
import ColorLensIcon from '@mui/icons-material/ColorLens';
|
||||
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
|
||||
import { MangaType } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { AppRoutes } from '@/base/AppRoute.constants.ts';
|
||||
import { CategorySelect } from '@/features/category/components/CategorySelect.tsx';
|
||||
import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
|
||||
import { useAppThemeContext } from '@/features/theme/AppThemeContext.tsx';
|
||||
import { createAppColorTheme } from '@/features/theme/services/ThemeCreator.ts';
|
||||
import { getTheme } from '@/features/theme/services/AppThemes.ts';
|
||||
import { ThemeCreationDialog } from '@/features/theme/components/CreateThemeDialog.tsx';
|
||||
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
|
||||
|
||||
interface IProps {
|
||||
manga: Pick<MangaType, 'id' | 'inLibrary' | 'sourceId' | 'title'>;
|
||||
@@ -37,6 +44,8 @@ export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
|
||||
|
||||
const theme = useTheme();
|
||||
const isLargeScreen = useMediaQuery(theme.breakpoints.up('sm'));
|
||||
const { settings } = useMetadataServerSettings();
|
||||
const { dynamicColor, appTheme, shouldUsePureBlackMode, themeMode } = useAppThemeContext();
|
||||
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
@@ -48,10 +57,37 @@ export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
|
||||
AwaitableComponent.show(CategorySelect, { mangaId: manga.id });
|
||||
};
|
||||
|
||||
const saveDynamicColorTheme = () => {
|
||||
AwaitableComponent.show(ThemeCreationDialog, {
|
||||
mode: 'create',
|
||||
appTheme: {
|
||||
id: '',
|
||||
getName: () => '',
|
||||
isCustom: true,
|
||||
muiTheme: createAppColorTheme(
|
||||
getTheme(appTheme, settings.customThemes).muiTheme,
|
||||
dynamicColor,
|
||||
shouldUsePureBlackMode,
|
||||
MediaQuery.getThemeMode(themeMode),
|
||||
),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLargeScreen && (
|
||||
<>
|
||||
{settings.mangaDynamicColorSchemes && (
|
||||
<CustomTooltip
|
||||
title={t('settings.appearance.manga_dynamic_color_schemes.save')}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<IconButton onClick={saveDynamicColorTheme} disabled={refreshing} color="inherit">
|
||||
<ColorLensIcon />
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
)}
|
||||
<CustomTooltip title={t('manga.label.reload_from_source')} disabled={refreshing}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
@@ -127,6 +163,14 @@ export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('manga.label.reload_from_source')}</ListItemText>
|
||||
</MenuItem>
|
||||
{settings.mangaDynamicColorSchemes && (
|
||||
<MenuItem onClick={saveDynamicColorTheme}>
|
||||
<ListItemIcon>
|
||||
<ColorLensIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('settings.appearance.manga_dynamic_color_schemes.save')}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
{manga.inLibrary && [
|
||||
<MenuItem
|
||||
key="migrate"
|
||||
|
||||
@@ -31,6 +31,8 @@ import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholde
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import { MetadataThemeSettings } from '@/features/theme/AppTheme.types.ts';
|
||||
import { TranslationKey } from '@/base/Base.types.ts';
|
||||
import { CheckboxInput } from '@/base/components/inputs/CheckboxInput.tsx';
|
||||
import { useAppThemeContext } from '@/features/theme/AppThemeContext.tsx';
|
||||
|
||||
const baseCustomTheme: AppTheme = {
|
||||
id: '',
|
||||
@@ -62,7 +64,7 @@ const baseCustomTheme: AppTheme = {
|
||||
},
|
||||
};
|
||||
|
||||
type DialogMode = 'create' | 'edit';
|
||||
type DialogMode = 'create' | 'edit' | 'save_dynamic';
|
||||
|
||||
const dialogModeToTranslationKey: Record<
|
||||
DialogMode,
|
||||
@@ -88,6 +90,13 @@ const dialogModeToTranslationKey: Record<
|
||||
success: 'settings.appearance.theme.edit.success',
|
||||
failure: 'settings.appearance.theme.edit.failure',
|
||||
},
|
||||
save_dynamic: {
|
||||
title: 'settings.appearance.manga_dynamic_color_schemes.save',
|
||||
button: 'global.button.save',
|
||||
action: 'settings.appearance.theme.create.action',
|
||||
success: 'settings.appearance.theme.create.success',
|
||||
failure: 'settings.appearance.theme.create.failure',
|
||||
},
|
||||
};
|
||||
|
||||
export const ThemeCreationDialog = ({
|
||||
@@ -107,6 +116,7 @@ export const ThemeCreationDialog = ({
|
||||
settings: { customThemes },
|
||||
request: { loading, error, refetch },
|
||||
} = useMetadataServerSettings();
|
||||
const { setAppTheme } = useAppThemeContext();
|
||||
|
||||
const updateCustomThemes = createUpdateMetadataServerSettings<keyof MetadataThemeSettings>((e) => {
|
||||
throw e;
|
||||
@@ -117,6 +127,7 @@ export const ThemeCreationDialog = ({
|
||||
const [invalidTheme, setInvalidTheme] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [didThemeChange, setDidThemeChange] = useState(mode === 'create');
|
||||
const [setAsActiveTheme, setSetAsActiveTheme] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={isVisible} onTransitionExited={onExitComplete} fullWidth maxWidth="md">
|
||||
@@ -214,36 +225,55 @@ export const ThemeCreationDialog = ({
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button autoFocus onClick={() => onDismiss()} color="primary">
|
||||
{t('global.button.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={invalidTheme || invalidName || isCreating || !didThemeChange || !theme.id.length}
|
||||
onClick={() => {
|
||||
makeToast(t(dialogModeToTranslationKey[mode].action, { theme: theme.getName() }), 'info');
|
||||
|
||||
setIsCreating(true);
|
||||
updateCustomThemes('customThemes', { ...customThemes, [theme.id]: theme })
|
||||
.then(() => {
|
||||
<Stack sx={{ width: '100%' }}>
|
||||
<CheckboxInput
|
||||
sx={{ margin: 0 }}
|
||||
size="small"
|
||||
label={t('settings.appearance.manga_dynamic_color_schemes.set_active')}
|
||||
checked={setAsActiveTheme}
|
||||
onChange={(_, checked) => setSetAsActiveTheme(checked)}
|
||||
/>
|
||||
<Stack sx={{ flexDirection: 'row', justifyContent: 'end' }}>
|
||||
<Button autoFocus onClick={() => onDismiss()} color="primary">
|
||||
{t('global.button.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={invalidTheme || invalidName || isCreating || !didThemeChange || !theme.id.length}
|
||||
onClick={() => {
|
||||
makeToast(
|
||||
t(dialogModeToTranslationKey[mode].success, { theme: theme.getName() }),
|
||||
'success',
|
||||
t(dialogModeToTranslationKey[mode].action, { theme: theme.getName() }),
|
||||
'info',
|
||||
);
|
||||
onSubmit();
|
||||
})
|
||||
.catch((updateError) =>
|
||||
makeToast(
|
||||
t(dialogModeToTranslationKey[mode].failure, { theme: theme.getName() }),
|
||||
'error',
|
||||
getErrorMessage(updateError),
|
||||
),
|
||||
)
|
||||
.finally(() => setIsCreating(false));
|
||||
}}
|
||||
color="primary"
|
||||
>
|
||||
{t(dialogModeToTranslationKey[mode].button)}
|
||||
</Button>
|
||||
|
||||
setIsCreating(true);
|
||||
updateCustomThemes('customThemes', { ...customThemes, [theme.id]: theme })
|
||||
.then(() => {
|
||||
makeToast(
|
||||
t(dialogModeToTranslationKey[mode].success, { theme: theme.getName() }),
|
||||
'success',
|
||||
);
|
||||
|
||||
if (setAsActiveTheme) {
|
||||
setAppTheme(theme.getName());
|
||||
}
|
||||
|
||||
onSubmit();
|
||||
})
|
||||
.catch((updateError) =>
|
||||
makeToast(
|
||||
t(dialogModeToTranslationKey[mode].failure, { theme: theme.getName() }),
|
||||
'error',
|
||||
getErrorMessage(updateError),
|
||||
),
|
||||
)
|
||||
.finally(() => setIsCreating(false));
|
||||
}}
|
||||
color="primary"
|
||||
>
|
||||
{t(dialogModeToTranslationKey[mode].button)}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -19,13 +19,13 @@ import { useCallback } from 'react';
|
||||
import { deepmerge } from '@mui/utils';
|
||||
import { PaletteBackgroundChannel } from 'node_modules/@mui/material/esm/styles/createThemeWithVars';
|
||||
import { complement, hsl, parseToHsl } from 'polished';
|
||||
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
|
||||
import { AppTheme } from '@/features/theme/services/AppThemes.ts';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import { applyStyles } from '@/base/utils/ApplyStyles.ts';
|
||||
import { TAppThemeContext, ThemeMode } from '@/features/theme/AppTheme.types.ts';
|
||||
import { ThemeFontLoader } from '@/features/theme/services/ThemeFontLoader.ts';
|
||||
import { coerceIn } from '@/lib/HelperFunctions.ts';
|
||||
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
|
||||
|
||||
const SCROLLBAR_SIZE = 14;
|
||||
|
||||
@@ -108,7 +108,7 @@ const getVibrantColorForTheme = (
|
||||
return mode === ThemeMode.LIGHT ? palette.DarkVibrant.hex : palette.LightVibrant.hex;
|
||||
};
|
||||
|
||||
const createAppColorTheme = (
|
||||
export const createAppColorTheme = (
|
||||
appTheme: AppTheme['muiTheme'],
|
||||
dynamicColor: TAppThemeContext['dynamicColor'],
|
||||
setPureBlackMode: boolean,
|
||||
@@ -163,9 +163,7 @@ export const createTheme = (
|
||||
direction: Direction = 'ltr',
|
||||
dynamicColor: TAppThemeContext['dynamicColor'] = null,
|
||||
) => {
|
||||
const systemMode = MediaQuery.getSystemThemeMode();
|
||||
|
||||
const mode = themeMode === ThemeMode.SYSTEM ? systemMode : themeMode;
|
||||
const mode = MediaQuery.getThemeMode(themeMode);
|
||||
const isDarkMode = mode === ThemeMode.DARK;
|
||||
const setPureBlackMode = isDarkMode && pureBlackMode;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user