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