Switch to "colorSchemes" from "palette" API in mui theme

"colorSchemes" is the preferred api starting from mui v6
This commit is contained in:
schroda
2025-01-16 12:14:15 +01:00
parent 9255386b1c
commit a22a4bf546
14 changed files with 493 additions and 195 deletions

View File

@@ -41,6 +41,7 @@
"@mui/icons-material": "6.3.0", "@mui/icons-material": "6.3.0",
"@mui/material": "6.3.0", "@mui/material": "6.3.0",
"@mui/system": "6.3.0", "@mui/system": "6.3.0",
"@mui/utils": "^6.4.0",
"@mui/x-date-pickers": "7.23.3", "@mui/x-date-pickers": "7.23.3",
"apollo-upload-client": "18.0.1", "apollo-upload-client": "18.0.1",
"csstype": "3.1.3", "csstype": "3.1.3",

View File

@@ -1034,10 +1034,7 @@
"invalid_json": "Invalid json", "invalid_json": "Invalid json",
"invalid_name": "The theme name must be unique and has a limit of 16 characters" "invalid_name": "The theme name must be unique and has a limit of 16 characters"
}, },
"info": { "description": "See the official <0>MUI documentation</0> for how to customize the theme.\nThe palette API is not supported, you have to use the new <1>color schemes API</1>.\n\nYou can use theme creators like <2>MUI Theme Creator</2>, however, you have to adjust the created object according to the new color schemes API.\n\nIn case no background is defined, a background will be calculated based on the primary color",
"creating_theme": "Create a custom theme with the <2>MUI Theme Creator</2>, copy everything after \"themeOptions\" from \"{\" to \"}\" and paste it into the \"theme\" text field.",
"theme_mode_lock": "In case \"mode\" or \"type\" is defined in the \"palette\" object, the theme will be locked into this mode and will not change regardless of the selected apps theme mode"
},
"theme_name": "Name" "theme_name": "Name"
}, },
"failure": "Could not create theme", "failure": "Could not create theme",

15
src/lib/mui/MuiStyles.types.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
/*
* 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 '@mui/material/styles';
declare module '@mui/material/styles' {
interface CssThemeVariables {
enabled: true;
}
}

View File

@@ -26,7 +26,9 @@ export class Storage {
return this.parseValue(this.getItem(key), defaultValue); return this.parseValue(this.getItem(key), defaultValue);
} }
setItem(key: string, value: unknown, emitEvent: boolean = true): void { setItem(key: string, value: unknown, emitEvent: boolean = true, stringify: boolean = true): void {
const currentValue = this.getItem(key);
const fireEvent = (valueToStore: string | undefined) => { const fireEvent = (valueToStore: string | undefined) => {
if (!emitEvent) { if (!emitEvent) {
return; return;
@@ -35,7 +37,7 @@ export class Storage {
window.dispatchEvent( window.dispatchEvent(
new StorageEvent('storage', { new StorageEvent('storage', {
key, key,
oldValue: this.getItem(key), oldValue: currentValue,
newValue: valueToStore, newValue: valueToStore,
}), }),
); );
@@ -47,10 +49,10 @@ export class Storage {
return; return;
} }
const valueToStore = JSON.stringify(value); const valueToStore = stringify ? JSON.stringify(value) : value;
this.storage.setItem(key, valueToStore); this.storage.setItem(key, valueToStore as string);
fireEvent(valueToStore); fireEvent(valueToStore as string);
} }
} }

View File

@@ -6,7 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { Direction, StyledEngineProvider, ThemeProvider } from '@mui/material/styles'; import { Direction, StyledEngineProvider, ThemeProvider, useColorScheme } from '@mui/material/styles';
import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { BrowserRouter as Router } from 'react-router-dom'; import { BrowserRouter as Router } from 'react-router-dom';
import { QueryParamProvider } from 'use-query-params'; import { QueryParamProvider } from 'use-query-params';
@@ -58,6 +58,9 @@ export const AppContext: React.FC<Props> = ({ children }) => {
const [themeMode, setThemeMode] = useLocalStorage<ThemeMode>('themeMode', ThemeMode.SYSTEM); const [themeMode, setThemeMode] = useLocalStorage<ThemeMode>('themeMode', ThemeMode.SYSTEM);
const [pureBlackMode, setPureBlackMode] = useLocalStorage<boolean>('pureBlackMode', false); const [pureBlackMode, setPureBlackMode] = useLocalStorage<boolean>('pureBlackMode', false);
const { mode } = useColorScheme();
const actualThemeMode = mode ?? themeMode ?? 'dark';
const darkThemeContext = useMemo( const darkThemeContext = useMemo(
() => ({ () => ({
appTheme, appTheme,
@@ -71,8 +74,14 @@ export const AppContext: React.FC<Props> = ({ children }) => {
); );
const theme = useMemo( const theme = useMemo(
() => createAndSetTheme(themeMode, getTheme(appTheme, customThemes), pureBlackMode, currentDirection), () =>
[themeMode, currentDirection, systemThemeMode, pureBlackMode, appTheme, customThemes], createAndSetTheme(
actualThemeMode as ThemeMode,
getTheme(appTheme, customThemes),
pureBlackMode,
currentDirection,
),
[actualThemeMode, currentDirection, systemThemeMode, pureBlackMode, appTheme, customThemes],
); );
return ( return (

View File

@@ -85,7 +85,7 @@ export class MediaQuery {
} }
static getThemeMode(): Exclude<ThemeMode, 'system'> { static getThemeMode(): Exclude<ThemeMode, 'system'> {
const themeMode = AppStorage.local.getItemParsed<ThemeMode>('themeMode', ThemeMode.SYSTEM); const themeMode = AppStorage.local.getItem('mui-mode') as ThemeMode;
const isSystemMode = themeMode === ThemeMode.SYSTEM; const isSystemMode = themeMode === ThemeMode.SYSTEM;
if (isSystemMode) { if (isSystemMode) {

View File

@@ -15,6 +15,7 @@ import MenuItem from '@mui/material/MenuItem';
import ListSubheader from '@mui/material/ListSubheader'; import ListSubheader from '@mui/material/ListSubheader';
import Switch from '@mui/material/Switch'; import Switch from '@mui/material/Switch';
import Link from '@mui/material/Link'; import Link from '@mui/material/Link';
import { useColorScheme } from '@mui/material/styles';
import { NavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx'; import { NavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx';
import { ThemeMode, ThemeModeContext } from '@/modules/theme/contexts/ThemeModeContext.tsx'; import { ThemeMode, ThemeModeContext } from '@/modules/theme/contexts/ThemeModeContext.tsx';
import { Select } from '@/modules/core/components/inputs/Select.tsx'; import { Select } from '@/modules/core/components/inputs/Select.tsx';
@@ -23,7 +24,6 @@ import { NumberSetting } from '@/modules/core/components/settings/NumberSetting.
import { useLocalStorage } from '@/modules/core/hooks/useStorage.tsx'; import { useLocalStorage } from '@/modules/core/hooks/useStorage.tsx';
import { I18nResourceCode, i18nResources } from '@/i18n'; import { I18nResourceCode, i18nResources } from '@/i18n';
import { langCodeToName } from '@/modules/core/utils/Languages.ts'; import { langCodeToName } from '@/modules/core/utils/Languages.ts';
import { getTheme } from '@/modules/theme/services/AppThemes.ts';
import { ThemeList } from '@/modules/theme/components/ThemeList.tsx'; import { ThemeList } from '@/modules/theme/components/ThemeList.tsx';
import { import {
createUpdateMetadataServerSettings, createUpdateMetadataServerSettings,
@@ -35,10 +35,13 @@ import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'
import { makeToast } from '@/modules/core/utils/Toast.ts'; import { makeToast } from '@/modules/core/utils/Toast.ts';
import { MetadataThemeSettings } from '@/modules/theme/AppTheme.types.ts'; import { MetadataThemeSettings } from '@/modules/theme/AppTheme.types.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts'; import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { AppStorage } from '@/lib/storage/AppStorage.ts';
export const Appearance = () => { export const Appearance = () => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { themeMode, setThemeMode, pureBlackMode, setPureBlackMode, appTheme } = useContext(ThemeModeContext); const { themeMode, setThemeMode, pureBlackMode, setPureBlackMode } = useContext(ThemeModeContext);
const { mode, setMode } = useColorScheme();
const actualThemeMode = (mode ?? themeMode) as ThemeMode;
const { setTitle, setAction } = useContext(NavBarContext); const { setTitle, setAction } = useContext(NavBarContext);
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -59,8 +62,7 @@ export const Appearance = () => {
makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)), makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)),
); );
const isDarkMode = const isDarkMode = MediaQuery.getThemeMode() === ThemeMode.DARK;
getTheme(appTheme).muiTheme.palette?.mode === 'dark' || MediaQuery.getThemeMode() === ThemeMode.DARK;
const DEFAULT_ITEM_WIDTH = 300; const DEFAULT_ITEM_WIDTH = 300;
const [itemWidth, setItemWidth] = useLocalStorage<number>('ItemWidth', DEFAULT_ITEM_WIDTH); const [itemWidth, setItemWidth] = useLocalStorage<number>('ItemWidth', DEFAULT_ITEM_WIDTH);
@@ -89,7 +91,17 @@ export const Appearance = () => {
> >
<ListItem> <ListItem>
<ListItemText primary={t('settings.appearance.theme.mode')} /> <ListItemText primary={t('settings.appearance.theme.mode')} />
<Select<ThemeMode> value={themeMode} onChange={(e) => setThemeMode(e.target.value as ThemeMode)}> <Select<ThemeMode>
value={actualThemeMode}
onChange={(e) => {
const newMode = e.target.value as 'system' | 'light' | 'dark';
setThemeMode(newMode as ThemeMode);
setMode(newMode);
// in case a non "colorSchemes" mui theme is active, "setMode" does not update the mode ("mui-mode") value
AppStorage.local.setItem('mui-mode', newMode, true, false);
}}
>
<MenuItem key={ThemeMode.SYSTEM} value={ThemeMode.SYSTEM}> <MenuItem key={ThemeMode.SYSTEM} value={ThemeMode.SYSTEM}>
System System
</MenuItem> </MenuItem>

View File

@@ -6,22 +6,40 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { ThemeOptions } from '@mui/material/styles'; import { CssVarsThemeOptions } from '@mui/material/styles';
import { t as translate } from 'i18next'; import { t as translate } from 'i18next';
export type TBaseTheme = { isCustom: boolean; getName: () => string; muiTheme: ThemeOptions }; export type TBaseTheme = {
isCustom: boolean;
getName: () => string;
muiTheme: Omit<CssVarsThemeOptions, 'activeMode'>;
};
export const themes = { export const themes = {
default: { default: {
isCustom: false, isCustom: false,
getName: () => translate('global.label.default'), getName: () => translate('global.label.default'),
muiTheme: { muiTheme: {
palette: { colorSchemes: {
primary: { light: {
main: '#5b74ef', palette: {
primary: {
main: '#5b74ef',
},
secondary: {
main: '#efd65b',
},
},
}, },
secondary: { dark: {
main: '#efd65b', palette: {
primary: {
main: '#5b74ef',
},
secondary: {
main: '#efd65b',
},
},
}, },
}, },
}, },
@@ -30,12 +48,26 @@ export const themes = {
isCustom: false, isCustom: false,
getName: () => translate('settings.appearance.theme.themes.lavender'), getName: () => translate('settings.appearance.theme.themes.lavender'),
muiTheme: { muiTheme: {
palette: { colorSchemes: {
primary: { light: {
main: '#5c58dc', palette: {
primary: {
main: '#5c58dc',
},
secondary: {
main: '#d8dc58',
},
},
}, },
secondary: { dark: {
main: '#d8dc58', palette: {
primary: {
main: '#5c58dc',
},
secondary: {
main: '#d8dc58',
},
},
}, },
}, },
}, },
@@ -44,12 +76,26 @@ export const themes = {
isCustom: false, isCustom: false,
getName: () => translate('settings.appearance.theme.themes.dune'), getName: () => translate('settings.appearance.theme.themes.dune'),
muiTheme: { muiTheme: {
palette: { colorSchemes: {
primary: { light: {
main: '#897869', palette: {
primary: {
main: '#897869',
},
secondary: {
main: '#697a89',
},
},
}, },
secondary: { dark: {
main: '#697a89', palette: {
primary: {
main: '#897869',
},
secondary: {
main: '#697a89',
},
},
}, },
}, },
}, },
@@ -58,12 +104,26 @@ export const themes = {
isCustom: false, isCustom: false,
getName: () => translate('settings.appearance.theme.themes.rosegold'), getName: () => translate('settings.appearance.theme.themes.rosegold'),
muiTheme: { muiTheme: {
palette: { colorSchemes: {
primary: { light: {
main: '#E9A7A1', palette: {
primary: {
main: '#E9A7A1',
},
secondary: {
main: '#A1E3E9',
},
},
}, },
secondary: { dark: {
main: '#A1E3E9', palette: {
primary: {
main: '#E9A7A1',
},
secondary: {
main: '#A1E3E9',
},
},
}, },
}, },
}, },
@@ -72,12 +132,26 @@ export const themes = {
isCustom: false, isCustom: false,
getName: () => translate('settings.appearance.theme.themes.forest_dew'), getName: () => translate('settings.appearance.theme.themes.forest_dew'),
muiTheme: { muiTheme: {
palette: { colorSchemes: {
primary: { light: {
main: '#53a584', palette: {
primary: {
main: '#53a584',
},
secondary: {
main: '#a55374',
},
},
}, },
secondary: { dark: {
main: '#a55374', palette: {
primary: {
main: '#53a584',
},
secondary: {
main: '#a55374',
},
},
}, },
}, },
}, },
@@ -86,12 +160,26 @@ export const themes = {
isCustom: false, isCustom: false,
getName: () => translate('settings.appearance.theme.themes.mountain_sunset'), getName: () => translate('settings.appearance.theme.themes.mountain_sunset'),
muiTheme: { muiTheme: {
palette: { colorSchemes: {
primary: { light: {
main: '#c55a77', palette: {
primary: {
main: '#c55a77',
},
secondary: {
main: '#5ac5a8',
},
},
}, },
secondary: { dark: {
main: '#5ac5a8', palette: {
primary: {
main: '#c55a77',
},
secondary: {
main: '#5ac5a8',
},
},
}, },
}, },
}, },
@@ -100,12 +188,26 @@ export const themes = {
isCustom: false, isCustom: false,
getName: () => translate('settings.appearance.theme.themes.crimson'), getName: () => translate('settings.appearance.theme.themes.crimson'),
muiTheme: { muiTheme: {
palette: { colorSchemes: {
primary: { light: {
main: '#DC143C', palette: {
primary: {
main: '#DC143C',
},
secondary: {
main: '#14DCB4',
},
},
}, },
secondary: { dark: {
main: '#14DCB4', palette: {
primary: {
main: '#DC143C',
},
secondary: {
main: '#14DCB4',
},
},
}, },
}, },
}, },
@@ -114,12 +216,26 @@ export const themes = {
isCustom: false, isCustom: false,
getName: () => translate('settings.appearance.theme.themes.minty_miracles'), getName: () => translate('settings.appearance.theme.themes.minty_miracles'),
muiTheme: { muiTheme: {
palette: { colorSchemes: {
primary: { light: {
main: '#5CE6A1', palette: {
primary: {
main: '#5CE6A1',
},
secondary: {
main: '#E65CA1',
},
},
}, },
secondary: { dark: {
main: '#E65CA1', palette: {
primary: {
main: '#5CE6A1',
},
secondary: {
main: '#E65CA1',
},
},
}, },
}, },
}, },
@@ -128,12 +244,26 @@ export const themes = {
isCustom: false, isCustom: false,
getName: () => translate('settings.appearance.theme.themes.orange_juice'), getName: () => translate('settings.appearance.theme.themes.orange_juice'),
muiTheme: { muiTheme: {
palette: { colorSchemes: {
primary: { light: {
main: '#ffb546', palette: {
primary: {
main: '#ffb546',
},
secondary: {
main: '#4690ff',
},
},
}, },
secondary: { dark: {
main: '#4690ff', palette: {
primary: {
main: '#ffb546',
},
secondary: {
main: '#4690ff',
},
},
}, },
}, },
}, },
@@ -142,12 +272,26 @@ export const themes = {
isCustom: false, isCustom: false,
getName: () => translate('settings.appearance.theme.themes.bright_pink'), getName: () => translate('settings.appearance.theme.themes.bright_pink'),
muiTheme: { muiTheme: {
palette: { colorSchemes: {
primary: { light: {
main: '#FF007F', palette: {
primary: {
main: '#FF007F',
},
secondary: {
main: '#00FF80',
},
},
}, },
secondary: { dark: {
main: '#00FF80', palette: {
primary: {
main: '#FF007F',
},
secondary: {
main: '#00FF80',
},
},
}, },
}, },
}, },
@@ -156,12 +300,26 @@ export const themes = {
isCustom: false, isCustom: false,
getName: () => translate('settings.appearance.theme.themes.veronica'), getName: () => translate('settings.appearance.theme.themes.veronica'),
muiTheme: { muiTheme: {
palette: { colorSchemes: {
primary: { light: {
main: '#A020F0', palette: {
primary: {
main: '#A020F0',
},
secondary: {
main: '#70F020',
},
},
}, },
secondary: { dark: {
main: '#70F020', palette: {
primary: {
main: '#A020F0',
},
secondary: {
main: '#70F020',
},
},
}, },
}, },
}, },
@@ -170,12 +328,26 @@ export const themes = {
isCustom: false, isCustom: false,
getName: () => translate('settings.appearance.theme.themes.tree_frog_green'), getName: () => translate('settings.appearance.theme.themes.tree_frog_green'),
muiTheme: { muiTheme: {
palette: { colorSchemes: {
primary: { light: {
main: '#8ace31', palette: {
primary: {
main: '#8ace31',
},
secondary: {
main: '#7531CE',
},
},
}, },
secondary: { dark: {
main: '#7531CE', palette: {
primary: {
main: '#8ace31',
},
secondary: {
main: '#7531CE',
},
},
}, },
}, },
}, },

View File

@@ -37,12 +37,26 @@ const baseCustomTheme: AppTheme = {
isCustom: true, isCustom: true,
getName: () => '', getName: () => '',
muiTheme: { muiTheme: {
palette: { colorSchemes: {
primary: { light: {
main: '#1976d2', palette: {
primary: {
main: '#1976d2',
},
secondary: {
main: '#9c27b0',
},
},
}, },
secondary: { dark: {
main: '#9c27b0', palette: {
primary: {
main: '#1976d2',
},
secondary: {
main: '#9c27b0',
},
},
}, },
}, },
}, },
@@ -115,10 +129,23 @@ export const ThemeCreationDialog = ({
/> />
)} )}
{!error && ( {!error && (
<Stack sx={{ gap: 2 }}> <Stack sx={{ gap: 2, whiteSpace: 'pre-line' }}>
<Typography> <Typography>
<Trans i18nKey="settings.appearance.theme.create.dialog.info.creating_theme"> <Trans i18nKey="settings.appearance.theme.create.dialog.description">
Create a custom theme with the{' '} <Link
href="https://mui.com/material-ui/customization/how-to-customize/"
target="_blank"
rel="noreferrer"
>
MUI documentation
</Link>
<Link
href="https://mui.com/material-ui/customization/palette/#color-schemes"
target="_blank"
rel="noreferrer"
>
MUI documentation
</Link>
<Link <Link
href="https://zenoo.github.io/mui-theme-creator/" href="https://zenoo.github.io/mui-theme-creator/"
target="_blank" target="_blank"
@@ -130,7 +157,6 @@ export const ThemeCreationDialog = ({
&quot; and paste it into the &quot;theme&quot; text field. &quot; and paste it into the &quot;theme&quot; text field.
</Trans> </Trans>
</Typography> </Typography>
<Typography>{t('settings.appearance.theme.create.dialog.info.theme_mode_lock')}</Typography>
<TextField <TextField
disabled={mode === 'edit'} disabled={mode === 'edit'}
label={t('settings.appearance.theme.create.dialog.theme_name')} label={t('settings.appearance.theme.create.dialog.theme_name')}

View File

@@ -68,7 +68,7 @@ export const ThemeList = () => {
{allThemes.map((theme) => ( {allThemes.map((theme) => (
<ThemePreview <ThemePreview
key={theme.id} key={theme.id}
theme={theme} appTheme={theme}
onDelete={() => { onDelete={() => {
makeToast(t('settings.appearance.theme.delete.action', { theme: theme.getName() }), 'info'); makeToast(t('settings.appearance.theme.delete.action', { theme: theme.getName() }), 'info');
updateCustomThemes( updateCustomThemes(

View File

@@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
import { useContext, useMemo } from 'react'; import { useContext, useMemo } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import { styled, ThemeProvider } from '@mui/material/styles'; import { styled, ThemeProvider, useTheme } from '@mui/material/styles';
import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import Card from '@mui/material/Card'; import Card from '@mui/material/Card';
import CardActionArea from '@mui/material/CardActionArea'; import CardActionArea from '@mui/material/CardActionArea';
@@ -32,17 +32,21 @@ const ThemePreviewBadge = styled(Box)(() => ({
height: '20px', height: '20px',
})); }));
export const ThemePreview = ({ theme, onDelete }: { theme: AppTheme; onDelete: () => void }) => { export const ThemePreview = ({ appTheme, onDelete }: { appTheme: AppTheme; onDelete: () => void }) => {
const { getName } = theme; const { getName } = appTheme;
const { t } = useTranslation(); const { t } = useTranslation();
const { themeMode, setAppTheme, appTheme, pureBlackMode } = useContext(ThemeModeContext); const theme = useTheme();
const { themeMode, setAppTheme, appTheme: activeAppTheme, pureBlackMode } = useContext(ThemeModeContext);
const popupState = usePopupState({ variant: 'popover', popupId: `theme-edit-dialog-${theme.id}` }); const popupState = usePopupState({ variant: 'popover', popupId: `theme-edit-dialog-${appTheme.id}` });
const isSelected = theme.id === appTheme; const isSelected = appTheme.id === activeAppTheme;
const muiTheme = useMemo(() => createTheme(themeMode, theme, pureBlackMode), [theme, themeMode, pureBlackMode]); const muiTheme = useMemo(
() => createTheme(themeMode, appTheme, pureBlackMode),
[appTheme, themeMode, pureBlackMode],
);
return ( return (
<> <>
@@ -71,12 +75,15 @@ export const ThemePreview = ({ theme, onDelete }: { theme: AppTheme; onDelete: (
bottom: 0, bottom: 0,
left: 0, left: 0,
border: '4px solid', border: '4px solid',
borderColor: isSelected borderColor: isSelected ? muiTheme.palette.primary.light : muiTheme.palette.grey['100'],
? muiTheme.palette.primary.light
: muiTheme.palette.background.paper,
zIndex: 1, zIndex: 1,
pointerEvents: 'none', pointerEvents: 'none',
borderRadius: 1, borderRadius: 1,
...theme.applyStyles('dark', {
borderColor: isSelected
? muiTheme.palette.primary.light
: muiTheme.palette.grey['900'],
}),
}} }}
/> />
<CardActionArea <CardActionArea
@@ -85,15 +92,15 @@ export const ThemePreview = ({ theme, onDelete }: { theme: AppTheme; onDelete: (
backgroundColor: 'background.default', backgroundColor: 'background.default',
}} }}
onClick={() => { onClick={() => {
const needToLoadFonts = hasMissingFonts(theme.muiTheme); const needToLoadFonts = hasMissingFonts(appTheme.muiTheme);
if (!needToLoadFonts) { if (!needToLoadFonts) {
setAppTheme(theme.id); setAppTheme(appTheme.id);
return; return;
} }
makeToast(t('settings.appearance.theme.select.fonts.loading'), 'info'); makeToast(t('settings.appearance.theme.select.fonts.loading'), 'info');
loadThemeFonts(theme.muiTheme) loadThemeFonts(appTheme.muiTheme)
.then(() => setAppTheme(theme.id)) .then(() => setAppTheme(appTheme.id))
.catch((e) => .catch((e) =>
makeToast( makeToast(
t('settings.appearance.theme.select.fonts.error'), t('settings.appearance.theme.select.fonts.error'),
@@ -136,7 +143,7 @@ export const ThemePreview = ({ theme, onDelete }: { theme: AppTheme; onDelete: (
}} }}
/> />
)} )}
{!isSelected && theme.isCustom && ( {!isSelected && appTheme.isCustom && (
<IconButton <IconButton
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@@ -152,7 +159,7 @@ export const ThemePreview = ({ theme, onDelete }: { theme: AppTheme; onDelete: (
</Tooltip> </Tooltip>
</IconButton> </IconButton>
)} )}
{theme.isCustom && ( {appTheme.isCustom && (
<IconButton <IconButton
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@@ -194,7 +201,7 @@ export const ThemePreview = ({ theme, onDelete }: { theme: AppTheme; onDelete: (
<Stack <Stack
sx={{ sx={{
height: '25%', height: '25%',
backgroundColor: muiTheme.palette.background.paper, backgroundColor: 'background.paper',
alignItems: 'center', alignItems: 'center',
flexDirection: 'row', flexDirection: 'row',
gap: 2, gap: 2,
@@ -226,7 +233,7 @@ export const ThemePreview = ({ theme, onDelete }: { theme: AppTheme; onDelete: (
<TypographyMaxLines sx={{ maxWidth: '100%' }}>{getName()}</TypographyMaxLines> <TypographyMaxLines sx={{ maxWidth: '100%' }}>{getName()}</TypographyMaxLines>
</Tooltip> </Tooltip>
</Stack> </Stack>
<ThemeCreationDialog bindDialogProps={bindDialog(popupState)} mode="edit" appTheme={theme} /> <ThemeCreationDialog bindDialogProps={bindDialog(popupState)} mode="edit" appTheme={appTheme} />
</> </>
); );
}; };

View File

@@ -6,7 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { ThemeOptions } from '@mui/material/styles'; import { CssVarsThemeOptions } from '@mui/material/styles';
import WebFont from 'webfontloader'; import WebFont from 'webfontloader';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { ControlledPromise } from '@/lib/ControlledPromise.ts'; import { ControlledPromise } from '@/lib/ControlledPromise.ts';
@@ -66,12 +66,12 @@ const getFontsFromTheme = (obj: Record<string, any>, fonts: string[] = []): stri
const loadedFonts: string[] = []; const loadedFonts: string[] = [];
export const hasMissingFonts = (theme: ThemeOptions): boolean => { export const hasMissingFonts = (theme: CssVarsThemeOptions): boolean => {
const themeFonts = getFontsFromTheme(theme.typography ?? {}); const themeFonts = getFontsFromTheme(theme.typography ?? {});
return !!themeFonts.length && themeFonts.some((font) => !loadedFonts.includes(font)); return !!themeFonts.length && themeFonts.some((font) => !loadedFonts.includes(font));
}; };
export const loadThemeFonts = async (theme: ThemeOptions): Promise<void> => { export const loadThemeFonts = async (theme: CssVarsThemeOptions): Promise<void> => {
const themeFonts = getFontsFromTheme(theme.typography ?? {}); const themeFonts = getFontsFromTheme(theme.typography ?? {});
const missingThemeFonts = themeFonts.filter((font) => !loadedFonts.includes(font)); const missingThemeFonts = themeFonts.filter((font) => !loadedFonts.includes(font));

View File

@@ -11,12 +11,16 @@ import {
darken, darken,
Direction, Direction,
lighten, lighten,
Palette,
responsiveFontSizes, responsiveFontSizes,
Theme, Theme,
TypeBackground,
useTheme, useTheme,
} from '@mui/material/styles'; } from '@mui/material/styles';
import { useCallback } from 'react'; import { useCallback } from 'react';
// eslint-disable-next-line import/no-extraneous-dependencies,no-restricted-imports
import { deepmerge } from '@mui/utils';
// eslint-disable-next-line no-restricted-imports
import { PaletteBackgroundChannel } from '@mui/material/styles/createThemeWithVars';
import { ThemeMode } from '@/modules/theme/contexts/ThemeModeContext.tsx'; import { ThemeMode } from '@/modules/theme/contexts/ThemeModeContext.tsx';
import { MediaQuery } from '@/modules/core/utils/MediaQuery.tsx'; import { MediaQuery } from '@/modules/core/utils/MediaQuery.tsx';
import { AppTheme, loadThemeFonts } from '@/modules/theme/services/AppThemes.ts'; import { AppTheme, loadThemeFonts } from '@/modules/theme/services/AppThemes.ts';
@@ -25,6 +29,56 @@ import { applyStyles } from '@/modules/core/utils/ApplyStyles.ts';
const SCROLLBAR_SIZE = 14; const SCROLLBAR_SIZE = 14;
declare module '@mui/material/styles' {
interface CssThemeVariables {
enabled: true;
}
}
const getBackgroundColor = (
type: 'light' | 'dark',
appTheme: AppTheme,
theme: Theme,
setPureBlackMode: boolean = false,
): (Partial<TypeBackground> & Partial<PaletteBackgroundChannel>) | undefined => {
if (setPureBlackMode) {
return {
paper: '#111',
default: '#000',
};
}
if (type === 'light' && !!theme.colorSchemes.light) {
if (
typeof appTheme.muiTheme.colorSchemes?.light === 'object' &&
appTheme.muiTheme.colorSchemes.light.palette?.background
) {
return appTheme.muiTheme.colorSchemes.light.palette.background;
}
return {
paper: lighten(theme.colorSchemes.light.palette.primary.dark, 0.8),
default: lighten(theme.colorSchemes.light.palette.primary.dark, 0.9),
};
}
if (type === 'dark' && !!theme.colorSchemes.dark) {
if (
typeof appTheme.muiTheme.colorSchemes?.dark === 'object' &&
appTheme.muiTheme.colorSchemes.dark.palette?.background
) {
return appTheme.muiTheme.colorSchemes.dark.palette.background;
}
return {
paper: darken(theme.colorSchemes.dark.palette.primary.dark, 0.8),
default: darken(theme.colorSchemes.dark.palette.primary.dark, 0.9),
};
}
return undefined;
};
export const createTheme = ( export const createTheme = (
themeMode: ThemeMode, themeMode: ThemeMode,
appTheme: AppTheme, appTheme: AppTheme,
@@ -33,93 +87,78 @@ export const createTheme = (
) => { ) => {
const systemMode = MediaQuery.getSystemThemeMode(); const systemMode = MediaQuery.getSystemThemeMode();
const appThemeType = (appTheme.muiTheme.palette as any)?.type ?? appTheme.muiTheme.palette?.mode; const mode = themeMode === ThemeMode.SYSTEM ? systemMode : themeMode;
const isStaticThemeMode = !!appThemeType;
const appThemeMode = appThemeType === 'dark' ? ThemeMode.DARK : ThemeMode.LIGHT;
const staticThemeMode = isStaticThemeMode ? appThemeMode : undefined;
const mode = staticThemeMode ?? (themeMode === ThemeMode.SYSTEM ? systemMode : themeMode);
const isDarkMode = mode === ThemeMode.DARK; const isDarkMode = mode === ThemeMode.DARK;
const setPureBlackMode = isDarkMode && pureBlackMode; const setPureBlackMode = isDarkMode && pureBlackMode;
const baseTheme = createMuiTheme({ const themeForColors = createMuiTheme({ ...appTheme.muiTheme, defaultColorScheme: mode });
direction,
...appTheme.muiTheme,
palette: {
mode,
...(appTheme.muiTheme.palette ?? {}),
},
});
const backgroundTrueBlack: Palette['background'] = { const suwayomiTheme = createMuiTheme(
paper: '#111', deepmerge(appTheme.muiTheme, {
default: '#000', defaultColorScheme: mode,
}; direction,
const backgroundDark: Palette['background'] = { colorSchemes: {
paper: darken(baseTheme.palette.primary.dark, 0.75), light: appTheme.muiTheme.colorSchemes?.light
default: darken(baseTheme.palette.primary.dark, 0.85), ? {
}; palette: {
const backgroundLight: Palette['background'] = { background: getBackgroundColor('light', appTheme, themeForColors),
paper: lighten(baseTheme.palette.primary.dark, 0.8), },
default: lighten(baseTheme.palette.primary.dark, 0.9), }
}; : undefined,
const backgroundThemeMode = isDarkMode ? backgroundDark : backgroundLight; dark: appTheme.muiTheme.colorSchemes?.dark
const automaticBackground = setPureBlackMode ? backgroundTrueBlack : backgroundThemeMode; ? {
const appThemeBackground = appTheme.muiTheme.palette?.background; palette: {
background: getBackgroundColor('dark', appTheme, themeForColors, setPureBlackMode),
const requiresAutomaticBackground = setPureBlackMode || !appThemeBackground; },
const background = requiresAutomaticBackground ? automaticBackground : appThemeBackground; }
: undefined,
const colorTheme = createMuiTheme(baseTheme, {
palette: {
background,
},
});
const suwayomiTheme = createMuiTheme(colorTheme, {
components: {
...appTheme.muiTheme.components,
MuiUseMediaQuery: {
defaultProps: {
noSsr: true,
},
}, },
MuiCssBaseline: { components: {
...appTheme.muiTheme.components?.MuiCssBaseline, ...appTheme.muiTheme.components,
styleOverrides: MuiUseMediaQuery: {
typeof appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides === 'object' defaultProps: {
? { noSsr: true,
...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides, },
'*::-webkit-scrollbar': applyStyles(CSS.supports('-webkit-touch-callout', 'none'), { },
width: `${SCROLLBAR_SIZE}px`, MuiCssBaseline: {
height: `${SCROLLBAR_SIZE}px`, ...appTheme.muiTheme.components?.MuiCssBaseline,
// @ts-ignore - '*::-webkit-scrollbar' is a valid key styleOverrides:
...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides?.[ typeof appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides === 'object'
'*::-webkit-scrollbar' ? {
], ...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides,
}), '*::-webkit-scrollbar': applyStyles(CSS.supports('-webkit-touch-callout', 'none'), {
'*::-webkit-scrollbar-thumb': applyStyles(CSS.supports('-webkit-touch-callout', 'none'), { width: `${SCROLLBAR_SIZE}px`,
border: '4px solid rgba(0, 0, 0, 0)', height: `${SCROLLBAR_SIZE}px`,
backgroundClip: 'padding-box', // @ts-ignore - '*::-webkit-scrollbar' is a valid key
borderRadius: '9999px',
backgroundColor: `${colorTheme.palette.primary[isDarkMode ? 'dark' : 'light']}`,
// @ts-ignore - '*::-webkit-scrollbar-thumb' is a valid key
...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides?.[
'*::-webkit-scrollbar-thumb'
],
}),
'*::-webkit-scrollbar-thumb:hover': applyStyles(
CSS.supports('-webkit-touch-callout', 'none'),
{
borderWidth: '2px',
// @ts-ignore - '*::-webkit-scrollbar-thumb:hover' is a valid key
...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides?.[ ...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides?.[
'*::-webkit-scrollbar-thumb:hover' '*::-webkit-scrollbar'
], ],
}, }),
), '*::-webkit-scrollbar-thumb': applyStyles(
} CSS.supports('-webkit-touch-callout', 'none'),
: ` {
border: '4px solid rgba(0, 0, 0, 0)',
backgroundClip: 'padding-box',
borderRadius: '9999px',
backgroundColor: `${themeForColors.palette.primary[isDarkMode ? 'dark' : 'light']}`,
// @ts-ignore - '*::-webkit-scrollbar-thumb' is a valid key
...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides?.[
'*::-webkit-scrollbar-thumb'
],
},
),
'*::-webkit-scrollbar-thumb:hover': applyStyles(
CSS.supports('-webkit-touch-callout', 'none'),
{
borderWidth: '2px',
// @ts-ignore - '*::-webkit-scrollbar-thumb:hover' is a valid key
...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides?.[
'*::-webkit-scrollbar-thumb:hover'
],
},
),
}
: `
@supports not (-webkit-touch-callout: none) { @supports not (-webkit-touch-callout: none) {
/* CSS for other than iOS devices */ /* CSS for other than iOS devices */
*::-webkit-scrollbar { *::-webkit-scrollbar {
@@ -130,7 +169,7 @@ export const createTheme = (
border: 4px solid rgba(0, 0, 0, 0); border: 4px solid rgba(0, 0, 0, 0);
background-clip: padding-box; background-clip: padding-box;
border-radius: 9999px; border-radius: 9999px;
background-color: ${colorTheme.palette.primary[isDarkMode ? 'dark' : 'light']}; background-color: ${themeForColors.palette.primary[isDarkMode ? 'dark' : 'light']};
} }
*::-webkit-scrollbar-thumb:hover { *::-webkit-scrollbar-thumb:hover {
border-width: 2px; border-width: 2px;
@@ -139,9 +178,10 @@ export const createTheme = (
${appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides ?? ''} ${appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides ?? ''}
} }
`, `,
},
}, },
}, }),
}); );
return responsiveFontSizes(suwayomiTheme); return responsiveFontSizes(suwayomiTheme);
}; };

View File

@@ -2409,6 +2409,11 @@
resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.20.tgz#16d8c9178b42b62ba95bbedbda8f343feb373d1e" resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.20.tgz#16d8c9178b42b62ba95bbedbda8f343feb373d1e"
integrity sha512-straFHD7L8v05l/N5vcWk+y7eL9JF0C2mtph/y4BPm3gn2Eh61dDwDB65pa8DLss3WJfDXYC7Kx5yjP0EmXpgw== integrity sha512-straFHD7L8v05l/N5vcWk+y7eL9JF0C2mtph/y4BPm3gn2Eh61dDwDB65pa8DLss3WJfDXYC7Kx5yjP0EmXpgw==
"@mui/types@^7.2.21":
version "7.2.21"
resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.21.tgz#63f50874eda8e4a021a69aaa8ba9597369befda2"
integrity sha512-6HstngiUxNqLU+/DPqlUJDIPbzUBxIVHb1MmXP0eTWDIROiCR2viugXpEif0PPe2mLqqakPzzRClWAnK+8UJww==
"@mui/utils@^5.16.6 || ^6.0.0", "@mui/utils@^6.3.0": "@mui/utils@^5.16.6 || ^6.0.0", "@mui/utils@^6.3.0":
version "6.3.0" version "6.3.0"
resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-6.3.0.tgz#76c80cb663c7c1bdf782de50a5c3f6ff8bf38e21" resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-6.3.0.tgz#76c80cb663c7c1bdf782de50a5c3f6ff8bf38e21"
@@ -2421,6 +2426,18 @@
prop-types "^15.8.1" prop-types "^15.8.1"
react-is "^19.0.0" react-is "^19.0.0"
"@mui/utils@^6.4.0":
version "6.4.0"
resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-6.4.0.tgz#817d8135794b8741ad0267b90dc656361f1c0030"
integrity sha512-woOTATWNsTNR3YBh2Ixkj3l5RaxSiGoC9G8gOpYoFw1mZM77LWJeuMHFax7iIW4ahK0Cr35TF9DKtrafJmOmNQ==
dependencies:
"@babel/runtime" "^7.26.0"
"@mui/types" "^7.2.21"
"@types/prop-types" "^15.7.14"
clsx "^2.1.1"
prop-types "^15.8.1"
react-is "^19.0.0"
"@mui/x-date-pickers@7.23.3": "@mui/x-date-pickers@7.23.3":
version "7.23.3" version "7.23.3"
resolved "https://registry.yarnpkg.com/@mui/x-date-pickers/-/x-date-pickers-7.23.3.tgz#0c2b3db6a99e6e38aeb0980bfdf98468f415dded" resolved "https://registry.yarnpkg.com/@mui/x-date-pickers/-/x-date-pickers-7.23.3.tgz#0c2b3db6a99e6e38aeb0980bfdf98468f415dded"