Rename folder "modules" to "features"
This commit is contained in:
20
src/features/theme/AppTheme.types.ts
Normal file
20
src/features/theme/AppTheme.types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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 { AppTheme, AppThemes } from '@/features/theme/services/AppThemes.ts';
|
||||
import { ThemeMode } from '@/features/theme/contexts/AppThemeContext.tsx';
|
||||
|
||||
export type MetadataThemeSettings = {
|
||||
appTheme: AppThemes;
|
||||
themeMode: ThemeMode;
|
||||
shouldUsePureBlackMode: boolean;
|
||||
customThemes: Record<string, AppTheme>;
|
||||
mangaThumbnailBackdrop: boolean;
|
||||
mangaDynamicColorSchemes: boolean;
|
||||
mangaGridItemWidth: number;
|
||||
};
|
||||
23
src/features/theme/ThemeDirectionCache.ts
Normal file
23
src/features/theme/ThemeDirectionCache.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Direction } from '@mui/material/styles';
|
||||
import { EmotionCache } from '@emotion/react';
|
||||
import createCache from '@emotion/cache';
|
||||
import { prefixer } from 'stylis';
|
||||
import rtlPlugin from 'stylis-plugin-rtl';
|
||||
|
||||
export const DIRECTION_TO_CACHE: Record<Direction, EmotionCache> = {
|
||||
ltr: createCache({
|
||||
key: 'muiltr',
|
||||
}),
|
||||
rtl: createCache({
|
||||
key: 'muirtl',
|
||||
stylisPlugins: [prefixer, rtlPlugin],
|
||||
}),
|
||||
};
|
||||
415
src/features/theme/Themes.ts
Normal file
415
src/features/theme/Themes.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
/*
|
||||
* 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 { CssVarsThemeOptions } from '@mui/material/styles';
|
||||
import { t as translate } from 'i18next';
|
||||
|
||||
export type TBaseTheme = {
|
||||
isCustom: boolean;
|
||||
getName: () => string;
|
||||
muiTheme: Omit<CssVarsThemeOptions, 'activeMode'>;
|
||||
};
|
||||
|
||||
export const themes = {
|
||||
default: {
|
||||
isCustom: false,
|
||||
getName: () => translate('global.label.default'),
|
||||
muiTheme: {
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#5b74ef',
|
||||
},
|
||||
secondary: {
|
||||
main: '#efd65b',
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#5b74ef',
|
||||
},
|
||||
secondary: {
|
||||
main: '#efd65b',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
lavender: {
|
||||
isCustom: false,
|
||||
getName: () => translate('settings.appearance.theme.themes.lavender'),
|
||||
muiTheme: {
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#6D41C8',
|
||||
},
|
||||
secondary: {
|
||||
main: '#9bc841',
|
||||
},
|
||||
background: {
|
||||
paper: '#E4D5F8',
|
||||
default: '#EDE2FF',
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#a076fd',
|
||||
},
|
||||
secondary: {
|
||||
main: '#d2fd76',
|
||||
},
|
||||
background: {
|
||||
paper: '#1d193b',
|
||||
default: '#111129',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dune: {
|
||||
isCustom: false,
|
||||
getName: () => translate('settings.appearance.theme.themes.dune'),
|
||||
muiTheme: {
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#897869',
|
||||
},
|
||||
secondary: {
|
||||
main: '#697a89',
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#897869',
|
||||
},
|
||||
secondary: {
|
||||
main: '#697a89',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
rosegold: {
|
||||
isCustom: false,
|
||||
getName: () => translate('settings.appearance.theme.themes.rosegold'),
|
||||
muiTheme: {
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#C07F7A',
|
||||
},
|
||||
secondary: {
|
||||
main: '#7ABBC0',
|
||||
},
|
||||
background: {
|
||||
paper: '#EAE1E0',
|
||||
default: '#F3EFEE',
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#E9A7A1',
|
||||
},
|
||||
secondary: {
|
||||
main: '#A1E3E9',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'forest dew': {
|
||||
isCustom: false,
|
||||
getName: () => translate('settings.appearance.theme.themes.forest_dew'),
|
||||
muiTheme: {
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#53a584',
|
||||
},
|
||||
secondary: {
|
||||
main: '#a55374',
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#53a584',
|
||||
},
|
||||
secondary: {
|
||||
main: '#a55374',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'montain sunset': {
|
||||
isCustom: false,
|
||||
getName: () => translate('settings.appearance.theme.themes.mountain_sunset'),
|
||||
muiTheme: {
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#974258',
|
||||
},
|
||||
secondary: {
|
||||
main: '#429780',
|
||||
},
|
||||
background: {
|
||||
paper: '#e5d6da',
|
||||
default: '#f7f3f4',
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#c55a77',
|
||||
},
|
||||
secondary: {
|
||||
main: '#5ac5a8',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
crimson: {
|
||||
isCustom: false,
|
||||
getName: () => translate('settings.appearance.theme.themes.crimson'),
|
||||
muiTheme: {
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#DC143C',
|
||||
},
|
||||
secondary: {
|
||||
main: '#14DCB4',
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#DC143C',
|
||||
},
|
||||
secondary: {
|
||||
main: '#14DCB4',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'minty miracles': {
|
||||
isCustom: false,
|
||||
getName: () => translate('settings.appearance.theme.themes.minty_miracles'),
|
||||
muiTheme: {
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#00c56a',
|
||||
},
|
||||
secondary: {
|
||||
main: '#c5005c',
|
||||
},
|
||||
background: {
|
||||
paper: '#d6eae0',
|
||||
default: '#e9f3ee',
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#5CE6A1',
|
||||
},
|
||||
secondary: {
|
||||
main: '#E65CA1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'orange juice': {
|
||||
isCustom: false,
|
||||
getName: () => translate('settings.appearance.theme.themes.orange_juice'),
|
||||
muiTheme: {
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#e74c00',
|
||||
},
|
||||
secondary: {
|
||||
main: '#009ae7',
|
||||
},
|
||||
background: {
|
||||
paper: '#ede3d3',
|
||||
default: '#f5f0e8',
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#ffb546',
|
||||
},
|
||||
secondary: {
|
||||
main: '#4690ff',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'bright pink': {
|
||||
isCustom: false,
|
||||
getName: () => translate('settings.appearance.theme.themes.bright_pink'),
|
||||
muiTheme: {
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#FF007F',
|
||||
},
|
||||
secondary: {
|
||||
main: '#00FF80',
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#FF007F',
|
||||
},
|
||||
secondary: {
|
||||
main: '#00FF80',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
veronica: {
|
||||
isCustom: false,
|
||||
getName: () => translate('settings.appearance.theme.themes.veronica'),
|
||||
muiTheme: {
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#A020F0',
|
||||
},
|
||||
secondary: {
|
||||
main: '#70F020',
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#A020F0',
|
||||
},
|
||||
secondary: {
|
||||
main: '#70F020',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'tree frog green': {
|
||||
isCustom: false,
|
||||
getName: () => translate('settings.appearance.theme.themes.tree_frog_green'),
|
||||
muiTheme: {
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#4f9513',
|
||||
},
|
||||
secondary: {
|
||||
main: '#581395',
|
||||
},
|
||||
background: {
|
||||
paper: '#dde6d0',
|
||||
default: '#edf1e6',
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#8ace31',
|
||||
},
|
||||
secondary: {
|
||||
main: '#7531CE',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'ying and yang': {
|
||||
isCustom: false,
|
||||
getName: () => translate('settings.appearance.theme.themes.ying_and_yang'),
|
||||
muiTheme: {
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#000',
|
||||
},
|
||||
secondary: {
|
||||
main: '#fff',
|
||||
},
|
||||
background: {
|
||||
paper: '#efefef',
|
||||
default: '#fff',
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#fff',
|
||||
},
|
||||
secondary: {
|
||||
main: '#000',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<string, TBaseTheme>;
|
||||
46
src/features/theme/components/CreateThemeButton.tsx
Normal file
46
src/features/theme/components/CreateThemeButton.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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 Stack from '@mui/material/Stack';
|
||||
import Button from '@mui/material/Button';
|
||||
import AddCircleIcon from '@mui/icons-material/AddCircle';
|
||||
import { bindDialog, bindTrigger, usePopupState } from 'material-ui-popup-state/hooks';
|
||||
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
|
||||
import { ThemeCreationDialog } from '@/features/theme/components/CreateThemeDialog.tsx';
|
||||
import { TypographyMaxLines } from '@/features/core/components/texts/TypographyMaxLines.tsx';
|
||||
|
||||
export const CreateThemeButton = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const popupState = usePopupState({ variant: 'popover', popupId: 'theme-creation-dialog' });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack sx={{ alignItems: 'center', gap: 1, minWidth: '150px', maxWidth: '150px' }}>
|
||||
<Button
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '225px',
|
||||
}}
|
||||
variant="contained"
|
||||
size="large"
|
||||
{...bindTrigger(popupState)}
|
||||
>
|
||||
<AddCircleIcon fontSize="large" />
|
||||
</Button>
|
||||
<CustomTooltip title={t('settings.appearance.theme.create.title')} placement="top">
|
||||
<TypographyMaxLines sx={{ maxWidth: '100%' }}>
|
||||
{t('settings.appearance.theme.create.title')}
|
||||
</TypographyMaxLines>
|
||||
</CustomTooltip>
|
||||
</Stack>
|
||||
{popupState.isOpen && <ThemeCreationDialog bindDialogProps={bindDialog(popupState)} mode="create" />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
248
src/features/theme/components/CreateThemeDialog.tsx
Normal file
248
src/features/theme/components/CreateThemeDialog.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* 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 { Trans, useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Button from '@mui/material/Button';
|
||||
import { bindDialog } from 'material-ui-popup-state/hooks';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Link from '@mui/material/Link';
|
||||
import { jsonrepair } from 'jsonrepair';
|
||||
import { getErrorMessage, jsonSaveParse } from '@/lib/HelperFunctions.ts';
|
||||
import { AppTheme, isThemeNameUnique } from '@/features/theme/services/AppThemes.ts';
|
||||
import {
|
||||
createUpdateMetadataServerSettings,
|
||||
useMetadataServerSettings,
|
||||
} from '@/features/settings/services/ServerSettingsMetadata.ts';
|
||||
import { makeToast } from '@/features/core/utils/Toast.ts';
|
||||
import { EmptyView } from '@/features/core/components/feedback/EmptyView.tsx';
|
||||
import { LoadingPlaceholder } from '@/features/core/components/feedback/LoadingPlaceholder.tsx';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import { TranslationKey } from '@/Base.types.ts';
|
||||
import { MetadataThemeSettings } from '@/features/theme/AppTheme.types.ts';
|
||||
|
||||
const baseCustomTheme: AppTheme = {
|
||||
id: '',
|
||||
isCustom: true,
|
||||
getName: () => '',
|
||||
muiTheme: {
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#1976d2',
|
||||
},
|
||||
secondary: {
|
||||
main: '#9c27b0',
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#1976d2',
|
||||
},
|
||||
secondary: {
|
||||
main: '#9c27b0',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
type DialogMode = 'create' | 'edit';
|
||||
|
||||
const dialogModeToTranslationKey: Record<
|
||||
DialogMode,
|
||||
{
|
||||
title: TranslationKey;
|
||||
button: TranslationKey;
|
||||
action: TranslationKey;
|
||||
success: TranslationKey;
|
||||
failure: TranslationKey;
|
||||
}
|
||||
> = {
|
||||
create: {
|
||||
title: 'settings.appearance.theme.create.title',
|
||||
button: 'global.button.ok',
|
||||
action: 'settings.appearance.theme.create.action',
|
||||
success: 'settings.appearance.theme.create.success',
|
||||
failure: 'settings.appearance.theme.create.failure',
|
||||
},
|
||||
edit: {
|
||||
title: 'settings.appearance.theme.edit.title',
|
||||
button: 'global.button.save',
|
||||
action: 'settings.appearance.theme.edit.action',
|
||||
success: 'settings.appearance.theme.edit.success',
|
||||
failure: 'settings.appearance.theme.edit.failure',
|
||||
},
|
||||
};
|
||||
|
||||
export const ThemeCreationDialog = ({
|
||||
bindDialogProps,
|
||||
mode,
|
||||
appTheme = baseCustomTheme,
|
||||
}: {
|
||||
bindDialogProps: ReturnType<typeof bindDialog>;
|
||||
mode: DialogMode;
|
||||
appTheme?: AppTheme;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
settings: { customThemes },
|
||||
request: { loading, error, refetch },
|
||||
} = useMetadataServerSettings();
|
||||
|
||||
const updateCustomThemes = createUpdateMetadataServerSettings<keyof MetadataThemeSettings>((e) => {
|
||||
throw e;
|
||||
});
|
||||
|
||||
const [theme, setTheme] = useState<AppTheme>(appTheme);
|
||||
const [invalidName, setInvalidName] = useState(false);
|
||||
const [invalidTheme, setInvalidTheme] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [didThemeChange, setDidThemeChange] = useState(mode === 'create');
|
||||
|
||||
return (
|
||||
<Dialog {...bindDialogProps} fullWidth maxWidth="md">
|
||||
<DialogTitle>{t(dialogModeToTranslationKey[mode].title)}</DialogTitle>
|
||||
<DialogContent>
|
||||
{loading && <LoadingPlaceholder />}
|
||||
{error && (
|
||||
<EmptyView
|
||||
message={t('global.error.label.failed_to_load_data')}
|
||||
messageExtra={getErrorMessage(error)}
|
||||
retry={() => refetch().catch(defaultPromiseErrorHandler('ThemeCreationDialog::refetch'))}
|
||||
/>
|
||||
)}
|
||||
{!error && (
|
||||
<Stack sx={{ gap: 2, whiteSpace: 'pre-line' }}>
|
||||
<Typography>
|
||||
<Trans i18nKey="settings.appearance.theme.create.dialog.description">
|
||||
<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
|
||||
href="https://zenoo.github.io/mui-theme-creator/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
MUI Theme Creator
|
||||
</Link>{' '}
|
||||
, copy everything after "themeOptions" from "{'{'}" to "{'}'}
|
||||
" and paste it into the "theme" text field.
|
||||
</Trans>
|
||||
</Typography>
|
||||
<TextField
|
||||
disabled={mode === 'edit'}
|
||||
label={t('settings.appearance.theme.create.dialog.theme_name')}
|
||||
value={theme.getName()}
|
||||
error={invalidName}
|
||||
helperText={invalidName && t('settings.appearance.theme.create.dialog.error.invalid_name')}
|
||||
onChange={(e) => {
|
||||
const name = e.target.value.trim();
|
||||
|
||||
const isValidLength = name.length <= 16;
|
||||
const isUnique = isThemeNameUnique(name, customThemes);
|
||||
setInvalidName(!isValidLength || !isUnique);
|
||||
|
||||
setTheme({
|
||||
...theme,
|
||||
id: e.target.value,
|
||||
getName: () => e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label={t('settings.appearance.theme.title')}
|
||||
multiline
|
||||
error={invalidTheme}
|
||||
helperText={invalidTheme && t('settings.appearance.theme.create.dialog.error.invalid_json')}
|
||||
defaultValue={JSON.stringify(theme.muiTheme, null, 2)}
|
||||
onChange={(e) => {
|
||||
const invalidThemeStr = 'invalid';
|
||||
const newMuiTheme = (() => {
|
||||
try {
|
||||
return jsonrepair(e.target.value);
|
||||
} catch (_) {
|
||||
return invalidThemeStr;
|
||||
}
|
||||
})();
|
||||
|
||||
const newMuiThemeParsed = jsonSaveParse(newMuiTheme);
|
||||
const isInvalid = newMuiTheme === invalidThemeStr || !newMuiThemeParsed;
|
||||
const isThemeDifferentFromOriginal =
|
||||
JSON.stringify(newMuiThemeParsed) !== JSON.stringify(appTheme.muiTheme);
|
||||
|
||||
setDidThemeChange(mode === 'create' ? true : isThemeDifferentFromOriginal);
|
||||
setInvalidTheme(isInvalid);
|
||||
|
||||
const didPaletteChange =
|
||||
!isInvalid && JSON.stringify(newMuiThemeParsed) !== JSON.stringify(theme.muiTheme);
|
||||
if (didPaletteChange) {
|
||||
setTheme({ ...theme, muiTheme: newMuiThemeParsed });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button autoFocus onClick={bindDialogProps.onClose} color="primary">
|
||||
{t('global.button.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={invalidTheme || invalidName || isCreating || !didThemeChange || !theme.id.length}
|
||||
onClick={(e) => {
|
||||
makeToast(t(dialogModeToTranslationKey[mode].action, { theme: theme.getName() }), 'info');
|
||||
|
||||
setIsCreating(true);
|
||||
updateCustomThemes('customThemes', { ...customThemes, [theme.id]: theme })
|
||||
.then(() => {
|
||||
makeToast(
|
||||
t(dialogModeToTranslationKey[mode].success, { theme: theme.getName() }),
|
||||
'success',
|
||||
);
|
||||
bindDialogProps.onClose(e);
|
||||
})
|
||||
.catch((updateError) =>
|
||||
makeToast(
|
||||
t(dialogModeToTranslationKey[mode].failure, { theme: theme.getName() }),
|
||||
'error',
|
||||
getErrorMessage(updateError),
|
||||
),
|
||||
)
|
||||
.finally(() => setIsCreating(false));
|
||||
}}
|
||||
color="primary"
|
||||
>
|
||||
{t(dialogModeToTranslationKey[mode].button)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
96
src/features/theme/components/ThemeList.tsx
Normal file
96
src/features/theme/components/ThemeList.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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 Stack from '@mui/material/Stack';
|
||||
import { useMemo } from 'react';
|
||||
import { appThemes } from '@/features/theme/services/AppThemes.ts';
|
||||
import {
|
||||
createUpdateMetadataServerSettings,
|
||||
useMetadataServerSettings,
|
||||
} from '@/features/settings/services/ServerSettingsMetadata.ts';
|
||||
import { makeToast } from '@/features/core/utils/Toast.ts';
|
||||
import { EmptyView } from '@/features/core/components/feedback/EmptyView.tsx';
|
||||
import { LoadingPlaceholder } from '@/features/core/components/feedback/LoadingPlaceholder.tsx';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import { CreateThemeButton } from '@/features/theme/components/CreateThemeButton.tsx';
|
||||
import { ThemePreview } from '@/features/theme/components/ThemePreview.tsx';
|
||||
import { MetadataThemeSettings } from '@/features/theme/AppTheme.types.ts';
|
||||
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
||||
|
||||
export const ThemeList = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
settings: { customThemes },
|
||||
request: { loading, error, refetch },
|
||||
} = useMetadataServerSettings();
|
||||
|
||||
const allThemes = useMemo(
|
||||
() => [
|
||||
...appThemes,
|
||||
...Object.values(customThemes).map((customTheme) => ({
|
||||
...customTheme,
|
||||
getName: () => customTheme.id,
|
||||
})),
|
||||
],
|
||||
[customThemes],
|
||||
);
|
||||
|
||||
const updateCustomThemes = createUpdateMetadataServerSettings<keyof MetadataThemeSettings>((e) => {
|
||||
throw e;
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return <LoadingPlaceholder />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyView
|
||||
message={t('global.error.label.failed_to_load_data')}
|
||||
messageExtra={getErrorMessage(error)}
|
||||
retry={() => refetch().catch(defaultPromiseErrorHandler('ThemeList::refetch'))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
sx={{ flexDirection: 'row', flexWrap: 'no-wrap', alignItems: 'start', overflowX: 'auto', gap: 1, mx: 2 }}
|
||||
>
|
||||
<CreateThemeButton />
|
||||
{allThemes.map((theme) => (
|
||||
<ThemePreview
|
||||
key={theme.id}
|
||||
appTheme={theme}
|
||||
onDelete={() => {
|
||||
makeToast(t('settings.appearance.theme.delete.action', { theme: theme.getName() }), 'info');
|
||||
updateCustomThemes(
|
||||
'customThemes',
|
||||
Object.fromEntries(Object.entries(customThemes).filter(([id]) => id !== theme.id)),
|
||||
)
|
||||
.then(() =>
|
||||
makeToast(
|
||||
t('settings.appearance.theme.delete.success', { theme: theme.getName() }),
|
||||
'success',
|
||||
),
|
||||
)
|
||||
.catch((e) =>
|
||||
makeToast(
|
||||
t('settings.appearance.theme.delete.failure', { theme: theme.getName() }),
|
||||
'error',
|
||||
getErrorMessage(e),
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
239
src/features/theme/components/ThemePreview.tsx
Normal file
239
src/features/theme/components/ThemePreview.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* 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 { useMemo } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { styled, ThemeProvider, useTheme } from '@mui/material/styles';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import { bindDialog, usePopupState } from 'material-ui-popup-state/hooks';
|
||||
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
|
||||
import { useAppThemeContext } from '@/features/theme/contexts/AppThemeContext.tsx';
|
||||
import { AppTheme, hasMissingFonts, loadThemeFonts } from '@/features/theme/services/AppThemes.ts';
|
||||
import { createTheme } from '@/features/theme/services/ThemeCreator.ts';
|
||||
import { ThemeCreationDialog } from '@/features/theme/components/CreateThemeDialog.tsx';
|
||||
import { makeToast } from '@/features/core/utils/Toast.ts';
|
||||
import { TypographyMaxLines } from '@/features/core/components/texts/TypographyMaxLines.tsx';
|
||||
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
||||
|
||||
const ThemePreviewBadge = styled(Box)(() => ({
|
||||
width: '15px',
|
||||
height: '20px',
|
||||
}));
|
||||
|
||||
export const ThemePreview = ({ appTheme, onDelete }: { appTheme: AppTheme; onDelete: () => void }) => {
|
||||
const { getName } = appTheme;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const { themeMode, setAppTheme, appTheme: activeAppTheme, shouldUsePureBlackMode } = useAppThemeContext();
|
||||
|
||||
const popupState = usePopupState({ variant: 'popover', popupId: `theme-edit-dialog-${appTheme.id}` });
|
||||
|
||||
const isSelected = appTheme.id === activeAppTheme;
|
||||
|
||||
const muiTheme = useMemo(
|
||||
() => createTheme(themeMode, appTheme, shouldUsePureBlackMode),
|
||||
[appTheme, themeMode, shouldUsePureBlackMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minWidth: '150px',
|
||||
maxWidth: '150px',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<ThemeProvider theme={muiTheme}>
|
||||
<Card
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '225px',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
border: '4px solid',
|
||||
borderColor: isSelected ? muiTheme.palette.primary.light : muiTheme.palette.grey['100'],
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none',
|
||||
borderRadius: 1,
|
||||
...theme.applyStyles('dark', {
|
||||
borderColor: isSelected
|
||||
? muiTheme.palette.primary.light
|
||||
: muiTheme.palette.grey['900'],
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<CardActionArea
|
||||
sx={{
|
||||
height: '100%',
|
||||
backgroundColor: 'background.default',
|
||||
}}
|
||||
onClick={() => {
|
||||
const needToLoadFonts = hasMissingFonts(appTheme.muiTheme);
|
||||
if (!needToLoadFonts) {
|
||||
setAppTheme(appTheme.id);
|
||||
return;
|
||||
}
|
||||
|
||||
makeToast(t('settings.appearance.theme.select.fonts.loading'), 'info');
|
||||
loadThemeFonts(appTheme.muiTheme)
|
||||
.then(() => setAppTheme(appTheme.id))
|
||||
.catch((e) =>
|
||||
makeToast(
|
||||
t('settings.appearance.theme.select.fonts.error'),
|
||||
'error',
|
||||
getErrorMessage(e),
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ height: '100%', m: 0 }}>
|
||||
<Stack sx={{ height: '100%', gap: 2, p: 2 }}>
|
||||
<Stack
|
||||
sx={{
|
||||
maxHeight: '20px',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '65%',
|
||||
height: '20px',
|
||||
backgroundColor: 'primary.dark',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
<Stack
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
mt: -0.25,
|
||||
}}
|
||||
>
|
||||
{isSelected && (
|
||||
<CheckCircleIcon
|
||||
sx={{
|
||||
visibility: isSelected ? 'visible' : 'hidden',
|
||||
color: 'primary.light',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isSelected && appTheme.isCustom && (
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
component="div"
|
||||
size="small"
|
||||
sx={{ mt: -0.5 }}
|
||||
>
|
||||
<CustomTooltip title={t('global.button.delete')}>
|
||||
<DeleteIcon />
|
||||
</CustomTooltip>
|
||||
</IconButton>
|
||||
)}
|
||||
{appTheme.isCustom && (
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
popupState.open();
|
||||
}}
|
||||
component="div"
|
||||
size="small"
|
||||
>
|
||||
<CustomTooltip title={t('global.button.edit')}>
|
||||
<EditIcon />
|
||||
</CustomTooltip>
|
||||
</IconButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
width: '55%',
|
||||
height: '65%',
|
||||
backgroundColor: 'background.paper',
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
sx={{
|
||||
width: 'max-content',
|
||||
flexDirection: 'row',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<ThemePreviewBadge sx={{ backgroundColor: 'primary.main' }} />
|
||||
<ThemePreviewBadge sx={{ backgroundColor: 'secondary.main' }} />
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack
|
||||
sx={{
|
||||
height: '25%',
|
||||
backgroundColor: 'background.paper',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 2,
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'primary.main',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
height: '20px',
|
||||
borderRadius: 1,
|
||||
backgroundColor: 'primary.light',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</ThemeProvider>
|
||||
<CustomTooltip title={getName()} placement="top">
|
||||
<TypographyMaxLines sx={{ maxWidth: '100%' }}>{getName()}</TypographyMaxLines>
|
||||
</CustomTooltip>
|
||||
</Stack>
|
||||
<ThemeCreationDialog bindDialogProps={bindDialog(popupState)} mode="edit" appTheme={appTheme} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
44
src/features/theme/contexts/AppThemeContext.tsx
Normal file
44
src/features/theme/contexts/AppThemeContext.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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 React, { useContext } from 'react';
|
||||
import { Palette } from '@vibrant/color';
|
||||
import { FastAverageColorResult } from 'fast-average-color';
|
||||
import { AppThemes } from '@/features/theme/services/AppThemes.ts';
|
||||
|
||||
export enum ThemeMode {
|
||||
SYSTEM = 'system',
|
||||
DARK = 'dark',
|
||||
LIGHT = 'light',
|
||||
}
|
||||
|
||||
export type TAppThemeContext = {
|
||||
appTheme: AppThemes;
|
||||
setAppTheme: (theme: AppThemes) => void;
|
||||
themeMode: ThemeMode;
|
||||
setThemeMode: (mode: ThemeMode) => void;
|
||||
shouldUsePureBlackMode: boolean;
|
||||
setShouldUsePureBlackMode: (value: boolean) => void;
|
||||
dynamicColor: (NonNullableProperties<Palette> & { average: FastAverageColorResult }) | null;
|
||||
setDynamicColor: React.Dispatch<
|
||||
React.SetStateAction<(NonNullableProperties<Palette> & { average: FastAverageColorResult }) | null>
|
||||
>;
|
||||
};
|
||||
|
||||
export const AppThemeContext = React.createContext<TAppThemeContext>({
|
||||
appTheme: 'default',
|
||||
setAppTheme: (): void => {},
|
||||
themeMode: ThemeMode.SYSTEM,
|
||||
setThemeMode: (): void => {},
|
||||
shouldUsePureBlackMode: false,
|
||||
setShouldUsePureBlackMode: (): void => {},
|
||||
dynamicColor: null,
|
||||
setDynamicColor: (): void => {},
|
||||
});
|
||||
|
||||
export const useAppThemeContext = () => useContext(AppThemeContext);
|
||||
126
src/features/theme/contexts/AppThemeContextProvider.tsx
Normal file
126
src/features/theme/contexts/AppThemeContextProvider.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* 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 { ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Direction, ThemeProvider } from '@mui/material/styles';
|
||||
import { CacheProvider } from '@emotion/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AppThemeContext, TAppThemeContext, ThemeMode } from '@/features/theme/contexts/AppThemeContext.tsx';
|
||||
import { DIRECTION_TO_CACHE } from '@/features/theme/ThemeDirectionCache.ts';
|
||||
import {
|
||||
createUpdateMetadataServerSettings,
|
||||
useMetadataServerSettings,
|
||||
} from '@/features/settings/services/ServerSettingsMetadata.ts';
|
||||
import { MediaQuery } from '@/features/core/utils/MediaQuery.tsx';
|
||||
import { AppTheme, getTheme } from '@/features/theme/services/AppThemes.ts';
|
||||
import { createAndSetTheme } from '@/features/theme/services/ThemeCreator.ts';
|
||||
import { makeToast } from '@/features/core/utils/Toast.ts';
|
||||
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
||||
import { useLocalStorage } from '@/features/core/hooks/useStorage.tsx';
|
||||
import { AppStorage } from '@/lib/storage/AppStorage.ts';
|
||||
import { MUI_THEME_MODE_KEY } from '@/lib/mui/MUI.constants.ts';
|
||||
|
||||
export const AppThemeContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const {
|
||||
request: metadataServerSettingsRequest,
|
||||
settings: { appTheme: serverAppTheme, themeMode, shouldUsePureBlackMode, customThemes },
|
||||
} = useMetadataServerSettings();
|
||||
const [localAppTheme, setLocalAppTheme] = useLocalStorage<AppTheme>(
|
||||
'appTheme',
|
||||
getTheme(serverAppTheme, customThemes),
|
||||
);
|
||||
const [localThemeMode] = useLocalStorage(MUI_THEME_MODE_KEY, themeMode);
|
||||
|
||||
const directionRef = useRef<Direction>('ltr');
|
||||
|
||||
const [systemThemeMode, setSystemThemeMode] = useState<ThemeMode>(MediaQuery.getSystemThemeMode());
|
||||
const [dynamicColor, setDynamicColor] = useState<TAppThemeContext['dynamicColor']>(null);
|
||||
|
||||
const areMetadataServerSettingsReady =
|
||||
!metadataServerSettingsRequest.loading && !metadataServerSettingsRequest.error;
|
||||
|
||||
const appTheme = areMetadataServerSettingsReady ? serverAppTheme : localAppTheme.id;
|
||||
const actualThemeMode = areMetadataServerSettingsReady ? themeMode : localThemeMode;
|
||||
const currentDirection = i18n.dir();
|
||||
|
||||
const updateSetting = createUpdateMetadataServerSettings<'appTheme' | 'themeMode' | 'shouldUsePureBlackMode'>((e) =>
|
||||
makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)),
|
||||
);
|
||||
|
||||
const appThemeContext = useMemo(
|
||||
() =>
|
||||
({
|
||||
appTheme,
|
||||
setAppTheme: (value) => updateSetting('appTheme', value),
|
||||
themeMode,
|
||||
setThemeMode: (value) => updateSetting('themeMode', value),
|
||||
shouldUsePureBlackMode,
|
||||
setShouldUsePureBlackMode: (value) => updateSetting('shouldUsePureBlackMode', value),
|
||||
dynamicColor,
|
||||
setDynamicColor,
|
||||
}) satisfies TAppThemeContext,
|
||||
[themeMode, shouldUsePureBlackMode, appTheme, dynamicColor],
|
||||
);
|
||||
|
||||
const theme = useMemo(
|
||||
() =>
|
||||
createAndSetTheme(
|
||||
actualThemeMode as ThemeMode,
|
||||
getTheme(appTheme, { [localAppTheme.id]: localAppTheme, ...customThemes }),
|
||||
shouldUsePureBlackMode,
|
||||
currentDirection,
|
||||
dynamicColor,
|
||||
),
|
||||
[
|
||||
actualThemeMode,
|
||||
currentDirection,
|
||||
systemThemeMode,
|
||||
shouldUsePureBlackMode,
|
||||
appTheme,
|
||||
customThemes,
|
||||
dynamicColor,
|
||||
],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const unsubscribe = MediaQuery.listenToSystemThemeChange(setSystemThemeMode);
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!areMetadataServerSettingsReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverAppTheme !== localAppTheme.id) {
|
||||
setLocalAppTheme(getTheme(serverAppTheme, customThemes));
|
||||
}
|
||||
}, [serverAppTheme, localAppTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
// The set background color is not necessary anymore, since the theme has been loaded
|
||||
document.documentElement.style.backgroundColor = '';
|
||||
|
||||
AppStorage.local.setItem('theme_background', theme.palette.background.default);
|
||||
}, [theme.palette.background.default]);
|
||||
|
||||
if (directionRef.current !== currentDirection) {
|
||||
document.dir = currentDirection;
|
||||
directionRef.current = currentDirection;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppThemeContext.Provider value={appThemeContext}>
|
||||
<CacheProvider value={DIRECTION_TO_CACHE[currentDirection]}>
|
||||
<ThemeProvider theme={theme}>{children}</ThemeProvider>
|
||||
</CacheProvider>
|
||||
</AppThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
104
src/features/theme/services/AppThemes.ts
Normal file
104
src/features/theme/services/AppThemes.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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 { CssVarsThemeOptions } from '@mui/material/styles';
|
||||
import WebFont from 'webfontloader';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import { ControlledPromise } from '@/lib/ControlledPromise.ts';
|
||||
import { TBaseTheme, themes } from '@/features/theme/Themes.ts';
|
||||
|
||||
export type AppThemes = keyof typeof themes | string;
|
||||
|
||||
export type AppTheme = TBaseTheme & { id: AppThemes; isCustom: boolean };
|
||||
|
||||
export const appThemes = (Object.entries(themes) as [AppThemes, TBaseTheme][]).map(([id, theme]) => ({
|
||||
id,
|
||||
...theme,
|
||||
})) satisfies AppTheme[];
|
||||
|
||||
export const getTheme = (id: AppThemes | undefined, customThemes: Record<string, AppTheme> = {}): AppTheme => {
|
||||
// TODO - This is a workaround to fix a type error which causes a white screen.
|
||||
// It should be removed and replaced with a proper migration logic, which would then e.g., add a migration to
|
||||
// remove deprecated/outdated settings from the local storage
|
||||
const actualId = id === undefined ? 'default' : id;
|
||||
|
||||
const allThemes = { ...themes, ...customThemes };
|
||||
const theme = (allThemes[actualId as keyof typeof themes] as AppTheme) ?? themes.default;
|
||||
|
||||
return {
|
||||
// @ts-ignore - custom themes do not have a "getName" function
|
||||
getName: () => id,
|
||||
// @ts-ignore - app themes do not have the "id" prop by default
|
||||
id,
|
||||
...theme,
|
||||
};
|
||||
};
|
||||
|
||||
export const isThemeNameUnique = (id: string, customThemes: Record<string, AppTheme>): boolean =>
|
||||
Object.keys({ ...themes, ...customThemes }).every((themeId) => themeId.toLowerCase() !== id.toLowerCase());
|
||||
|
||||
const getFontsFromTheme = (obj: Record<string, any>, fonts: string[] = []): string[] => {
|
||||
const tmpFonts = [...fonts];
|
||||
|
||||
const propertyNames = Object.keys(obj);
|
||||
for (const propertyName of propertyNames) {
|
||||
const propertyValue = obj[propertyName];
|
||||
const propertyType = typeof obj[propertyName];
|
||||
|
||||
const isValidFontProperty = propertyName === 'fontFamily' && propertyType === 'string';
|
||||
if (isValidFontProperty) {
|
||||
tmpFonts.push(propertyValue as string);
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
const isObject = propertyType === 'object';
|
||||
if (isObject) {
|
||||
tmpFonts.push(...getFontsFromTheme(propertyValue as Record<string, any>, tmpFonts));
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(tmpFonts)];
|
||||
};
|
||||
|
||||
const loadedFonts: string[] = [];
|
||||
|
||||
export const hasMissingFonts = (theme: CssVarsThemeOptions): boolean => {
|
||||
const themeFonts = getFontsFromTheme(theme.typography ?? {});
|
||||
return !!themeFonts.length && themeFonts.some((font) => !loadedFonts.includes(font));
|
||||
};
|
||||
|
||||
export const loadThemeFonts = async (theme: CssVarsThemeOptions): Promise<void> => {
|
||||
const themeFonts = getFontsFromTheme(theme.typography ?? {});
|
||||
const missingThemeFonts = themeFonts.filter((font) => !loadedFonts.includes(font));
|
||||
|
||||
if (!missingThemeFonts.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadFontsPromise = new ControlledPromise();
|
||||
try {
|
||||
WebFont.load({
|
||||
google: {
|
||||
families: missingThemeFonts,
|
||||
},
|
||||
active: () => {
|
||||
loadedFonts.push(...missingThemeFonts);
|
||||
loadFontsPromise.resolve();
|
||||
},
|
||||
inactive: () => {
|
||||
loadFontsPromise.reject();
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
defaultPromiseErrorHandler('AppThemes::loadFonts')(e);
|
||||
loadFontsPromise.reject(e);
|
||||
}
|
||||
|
||||
await loadFontsPromise.promise;
|
||||
};
|
||||
297
src/features/theme/services/ThemeCreator.ts
Normal file
297
src/features/theme/services/ThemeCreator.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/*
|
||||
* 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 {
|
||||
createTheme as createMuiTheme,
|
||||
darken,
|
||||
Direction,
|
||||
lighten,
|
||||
responsiveFontSizes,
|
||||
Theme,
|
||||
TypeBackground,
|
||||
useTheme,
|
||||
} from '@mui/material/styles';
|
||||
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 { Palette } from '@vibrant/color';
|
||||
import { TAppThemeContext, ThemeMode } from '@/features/theme/contexts/AppThemeContext.tsx';
|
||||
import { MediaQuery } from '@/features/core/utils/MediaQuery.tsx';
|
||||
import { AppTheme, loadThemeFonts } from '@/features/theme/services/AppThemes.ts';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
|
||||
|
||||
const SCROLLBAR_SIZE = 14;
|
||||
|
||||
const getBackgroundColor = (
|
||||
type: 'light' | 'dark',
|
||||
appTheme: AppTheme['muiTheme'],
|
||||
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.colorSchemes?.light === 'object' && appTheme.colorSchemes.light.palette?.background) {
|
||||
return appTheme.colorSchemes.light.palette.background;
|
||||
}
|
||||
|
||||
return {
|
||||
paper: lighten(theme.colorSchemes.light.palette.primary.dark, 0.7),
|
||||
default: lighten(theme.colorSchemes.light.palette.primary.dark, 0.8),
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'dark' && !!theme.colorSchemes.dark) {
|
||||
if (typeof appTheme.colorSchemes?.dark === 'object' && appTheme.colorSchemes.dark.palette?.background) {
|
||||
return appTheme.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;
|
||||
};
|
||||
|
||||
const createAppThemeWithDynamicPrimaryColor = (
|
||||
primaryColor: string | null | undefined,
|
||||
appTheme: AppTheme['muiTheme'],
|
||||
): AppTheme['muiTheme'] => {
|
||||
if (!primaryColor) {
|
||||
return appTheme;
|
||||
}
|
||||
|
||||
return {
|
||||
...appTheme,
|
||||
colorSchemes: {
|
||||
light: { palette: { primary: { main: primaryColor } } },
|
||||
dark: { palette: { primary: { main: primaryColor } } },
|
||||
},
|
||||
} satisfies AppTheme['muiTheme'];
|
||||
};
|
||||
|
||||
const getHighestPopulationColor = (
|
||||
palette: NonNullableProperties<Palette> | null,
|
||||
mode: Exclude<ThemeMode, ThemeMode.SYSTEM>,
|
||||
): string | null => {
|
||||
if (!palette) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let highestPopulation = 0;
|
||||
let highestPopulationKey: string = '';
|
||||
let highestPopulationColor: string = '';
|
||||
|
||||
// eslint-disable-next-line guard-for-in
|
||||
for (const key in palette) {
|
||||
const swatch = palette[key];
|
||||
|
||||
if (swatch.population > highestPopulation) {
|
||||
highestPopulationKey = key;
|
||||
highestPopulation = swatch.population;
|
||||
highestPopulationColor = swatch.hex;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === ThemeMode.LIGHT) {
|
||||
if (highestPopulationKey.startsWith('Light')) {
|
||||
return palette[highestPopulationKey.replace('Light', 'Dark')].hex;
|
||||
}
|
||||
|
||||
if (!highestPopulationKey.startsWith('Dark')) {
|
||||
return palette[`Dark${highestPopulationKey}`].hex;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === ThemeMode.DARK) {
|
||||
if (highestPopulationKey.startsWith('Dark')) {
|
||||
return palette[highestPopulationKey.replace('Dark', 'Light')].hex;
|
||||
}
|
||||
|
||||
if (!highestPopulationKey.startsWith('Light')) {
|
||||
return palette[`Light${highestPopulationKey}`].hex;
|
||||
}
|
||||
}
|
||||
|
||||
return highestPopulationColor;
|
||||
};
|
||||
|
||||
const createAppColorTheme = (
|
||||
appTheme: AppTheme['muiTheme'],
|
||||
dynamicColor: TAppThemeContext['dynamicColor'],
|
||||
setPureBlackMode: boolean,
|
||||
mode: Exclude<ThemeMode, ThemeMode.SYSTEM>,
|
||||
): AppTheme['muiTheme'] => {
|
||||
const appThemeWithDominantPrimaryColor = createAppThemeWithDynamicPrimaryColor(dynamicColor?.average.hex, appTheme);
|
||||
const themePrimaryColorForBackground = createMuiTheme({
|
||||
...appThemeWithDominantPrimaryColor,
|
||||
defaultColorScheme: mode,
|
||||
});
|
||||
|
||||
const themeBackgroundColor = deepmerge(appThemeWithDominantPrimaryColor, {
|
||||
defaultColorScheme: mode,
|
||||
colorSchemes: {
|
||||
light: themePrimaryColorForBackground.colorSchemes?.light
|
||||
? {
|
||||
palette: {
|
||||
background: getBackgroundColor(
|
||||
'light',
|
||||
appThemeWithDominantPrimaryColor,
|
||||
themePrimaryColorForBackground,
|
||||
),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
dark: themePrimaryColorForBackground.colorSchemes?.dark
|
||||
? {
|
||||
palette: {
|
||||
background: getBackgroundColor(
|
||||
'dark',
|
||||
appThemeWithDominantPrimaryColor,
|
||||
themePrimaryColorForBackground,
|
||||
setPureBlackMode,
|
||||
),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const appThemeWithVibrantPrimaryColor = createAppThemeWithDynamicPrimaryColor(
|
||||
getHighestPopulationColor(dynamicColor, mode),
|
||||
themeBackgroundColor,
|
||||
);
|
||||
return deepmerge(themeBackgroundColor, appThemeWithVibrantPrimaryColor);
|
||||
};
|
||||
|
||||
export const createTheme = (
|
||||
themeMode: ThemeMode,
|
||||
appTheme: AppTheme,
|
||||
pureBlackMode: boolean = false,
|
||||
direction: Direction = 'ltr',
|
||||
dynamicColor: TAppThemeContext['dynamicColor'] = null,
|
||||
) => {
|
||||
const systemMode = MediaQuery.getSystemThemeMode();
|
||||
|
||||
const mode = themeMode === ThemeMode.SYSTEM ? systemMode : themeMode;
|
||||
const isDarkMode = mode === ThemeMode.DARK;
|
||||
const setPureBlackMode = isDarkMode && pureBlackMode;
|
||||
|
||||
const appColorTheme = createAppColorTheme(appTheme.muiTheme, dynamicColor, setPureBlackMode, mode);
|
||||
|
||||
const themeForColors = createMuiTheme({ ...appColorTheme, defaultColorScheme: mode });
|
||||
|
||||
// only style scrollbar for devices that support hovering; otherwise, they most likely are touch devices that should
|
||||
// use the native scrollbar.
|
||||
// this is necessary since for some reason chromium uses the native scrollbar for the window scrollbar and the
|
||||
// styled non-native scrollbar for element scrollbars on those devices
|
||||
const doesDeviceSupportHover = window.matchMedia('hover: hover').matches;
|
||||
|
||||
const suwayomiTheme = createMuiTheme(
|
||||
deepmerge(appColorTheme, {
|
||||
defaultColorScheme: mode,
|
||||
direction,
|
||||
components: {
|
||||
...appTheme.muiTheme.components,
|
||||
MuiUseMediaQuery: {
|
||||
defaultProps: {
|
||||
noSsr: true,
|
||||
},
|
||||
},
|
||||
MuiCssBaseline: {
|
||||
...appTheme.muiTheme.components?.MuiCssBaseline,
|
||||
styleOverrides:
|
||||
typeof appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides === 'object'
|
||||
? {
|
||||
...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides,
|
||||
'*::-webkit-scrollbar': applyStyles(doesDeviceSupportHover, {
|
||||
width: `${SCROLLBAR_SIZE}px`,
|
||||
height: `${SCROLLBAR_SIZE}px`,
|
||||
// @ts-ignore - '*::-webkit-scrollbar' is a valid key
|
||||
...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides?.[
|
||||
'*::-webkit-scrollbar'
|
||||
],
|
||||
}),
|
||||
'*::-webkit-scrollbar-thumb': applyStyles(doesDeviceSupportHover, {
|
||||
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(doesDeviceSupportHover, {
|
||||
borderWidth: '2px',
|
||||
// @ts-ignore - '*::-webkit-scrollbar-thumb:hover' is a valid key
|
||||
...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides?.[
|
||||
'*::-webkit-scrollbar-thumb:hover'
|
||||
],
|
||||
}),
|
||||
}
|
||||
: `
|
||||
@media (hover: hover) {
|
||||
*::-webkit-scrollbar {
|
||||
width: ${SCROLLBAR_SIZE}px;
|
||||
height: ${SCROLLBAR_SIZE}px;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
border: 4px solid rgba(0, 0, 0, 0);
|
||||
background-clip: padding-box;
|
||||
border-radius: 9999px;
|
||||
background-color: ${themeForColors.palette.primary[isDarkMode ? 'dark' : 'light']};
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
${appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides ?? ''}
|
||||
}
|
||||
`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return responsiveFontSizes(suwayomiTheme);
|
||||
};
|
||||
|
||||
let theme: Theme;
|
||||
export const getCurrentTheme = () => theme;
|
||||
export const createAndSetTheme = (...args: Parameters<typeof createTheme>) => {
|
||||
theme = createTheme(...args);
|
||||
loadThemeFonts(theme).catch(defaultPromiseErrorHandler('theme::createAndSetTheme'));
|
||||
|
||||
return theme;
|
||||
};
|
||||
|
||||
export const getOptionForDirection = <T>(
|
||||
ltrOption: T,
|
||||
rtlOption: T,
|
||||
direction: Theme['direction'] = theme?.direction ?? 'ltr',
|
||||
): T => (direction === 'ltr' ? ltrOption : rtlOption);
|
||||
|
||||
export const useGetOptionForDirection = (): typeof getOptionForDirection => {
|
||||
const muiTheme = useTheme();
|
||||
|
||||
return useCallback(
|
||||
<T>(...args: Parameters<typeof getOptionForDirection<T>>) => getOptionForDirection(...args),
|
||||
[muiTheme.direction],
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user