Use custom themes
This commit is contained in:
@@ -1,262 +0,0 @@
|
||||
/*
|
||||
* 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 { useContext, useEffect, useMemo } from 'react';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Link from '@mui/material/Link';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { styled, ThemeProvider } from '@mui/material/styles';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import { NavBarContext } from '@/components/context/NavbarContext.tsx';
|
||||
import { ThemeMode, ThemeModeContext } from '@/components/context/ThemeModeContext.tsx';
|
||||
import { Select } from '@/components/atoms/Select.tsx';
|
||||
import { MediaQuery } from '@/lib/ui/MediaQuery.tsx';
|
||||
import { NumberSetting } from '@/components/settings/NumberSetting.tsx';
|
||||
import { useLocalStorage } from '@/util/useStorage.tsx';
|
||||
import { I18nResourceCode, i18nResources } from '@/i18n';
|
||||
import { langCodeToName } from '@/util/language.tsx';
|
||||
import { AppTheme, appThemes } from '@/lib/ui/AppThemes.ts';
|
||||
import { createTheme } from '@/theme.ts';
|
||||
|
||||
const ThemePreviewBadge = styled(Box)(({ theme }) => ({
|
||||
width: '15px',
|
||||
height: '20px',
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
}));
|
||||
|
||||
const ThemePreview = ({ theme }: { theme: AppTheme }) => {
|
||||
const { getName } = theme;
|
||||
|
||||
const { themeMode, setAppTheme, appTheme, pureBlackMode } = useContext(ThemeModeContext);
|
||||
|
||||
const isSelected = theme.id === appTheme;
|
||||
|
||||
const muiTheme = useMemo(
|
||||
() => createTheme(themeMode, theme.id, pureBlackMode),
|
||||
[theme.id, themeMode, pureBlackMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
sx={{
|
||||
padding: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minWidth: '150px',
|
||||
maxWidth: '150px',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<ThemeProvider theme={muiTheme}>
|
||||
<Card
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '225px',
|
||||
outline: '4px solid',
|
||||
outlineColor: isSelected ? muiTheme.palette.primary.light : muiTheme.palette.background.paper,
|
||||
borderRadius: 1,
|
||||
p: 0,
|
||||
}}
|
||||
>
|
||||
<CardActionArea
|
||||
sx={{ height: '100%', backgroundColor: 'background.default' }}
|
||||
onClick={() => setAppTheme(theme.id)}
|
||||
>
|
||||
<Stack sx={{ height: '100%' }}>
|
||||
<Stack sx={{ height: '100%', gap: 2, p: 1 }}>
|
||||
<Stack
|
||||
sx={{
|
||||
maxHeight: '20px',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '65%',
|
||||
height: '20px',
|
||||
backgroundColor: 'primary.dark',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
{isSelected && (
|
||||
<CheckCircleIcon
|
||||
sx={{
|
||||
visibility: isSelected ? 'visible' : 'hidden',
|
||||
color: 'primary.light',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack
|
||||
sx={{
|
||||
flexDirection: 'row',
|
||||
width: '55%',
|
||||
height: '65%',
|
||||
backgroundColor: 'background.paper',
|
||||
borderRadius: 1,
|
||||
p: 1,
|
||||
}}
|
||||
>
|
||||
<ThemePreviewBadge sx={{ backgroundColor: 'primary.main' }} />
|
||||
<ThemePreviewBadge sx={{ backgroundColor: 'secondary.main' }} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack
|
||||
sx={{
|
||||
height: '80px',
|
||||
backgroundColor: muiTheme.palette.background.paper,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'primary.main',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 0.75,
|
||||
height: '20px',
|
||||
borderRadius: 1,
|
||||
backgroundColor: 'primary.light',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</ThemeProvider>
|
||||
<Typography>{getName()}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const ThemePicker = () => (
|
||||
<Stack sx={{ flexDirection: 'row', flexWrap: 'no-wrap', overflowX: 'auto', gap: 3, mx: 1 }}>
|
||||
{appThemes.map((theme) => (
|
||||
<ThemePreview key={theme.id} theme={theme} />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export const Appearance = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { themeMode, setThemeMode, pureBlackMode, setPureBlackMode } = useContext(ThemeModeContext);
|
||||
const isDarkMode = MediaQuery.getThemeMode() === ThemeMode.DARK;
|
||||
|
||||
const { setTitle, setAction } = useContext(NavBarContext);
|
||||
useEffect(() => {
|
||||
setTitle(t('settings.appearance.title'));
|
||||
setAction(null);
|
||||
|
||||
return () => {
|
||||
setTitle('');
|
||||
setAction(null);
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const DEFAULT_ITEM_WIDTH = 300;
|
||||
const [itemWidth, setItemWidth] = useLocalStorage<number>('ItemWidth', DEFAULT_ITEM_WIDTH);
|
||||
|
||||
return (
|
||||
<List
|
||||
subheader={
|
||||
<ListSubheader component="div" id="appearance-theme">
|
||||
{t('settings.appearance.theme')}
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItem>
|
||||
<ListItemText primary={t('settings.appearance.device_theme')} />
|
||||
<Select<ThemeMode> value={themeMode} onChange={(e) => setThemeMode(e.target.value as ThemeMode)}>
|
||||
<MenuItem key={ThemeMode.SYSTEM} value={ThemeMode.SYSTEM}>
|
||||
System
|
||||
</MenuItem>
|
||||
<MenuItem key={ThemeMode.DARK} value={ThemeMode.DARK}>
|
||||
Dark
|
||||
</MenuItem>
|
||||
<MenuItem key={ThemeMode.LIGHT} value={ThemeMode.LIGHT}>
|
||||
Light
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</ListItem>
|
||||
<ThemePicker />
|
||||
{isDarkMode && (
|
||||
<ListItem>
|
||||
<ListItemText primary={t('settings.appearance.pure_black_mode')} />
|
||||
<Switch checked={pureBlackMode} onChange={(_, enabled) => setPureBlackMode(enabled)} />
|
||||
</ListItem>
|
||||
)}
|
||||
<List
|
||||
subheader={
|
||||
<ListSubheader component="div" id="appearance-theme">
|
||||
{t('global.label.display')}
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={t('global.language.label.language')}
|
||||
secondary={
|
||||
<>
|
||||
<span>{t('settings.label.language_description')} </span>
|
||||
<Link
|
||||
href="https://hosted.weblate.org/projects/suwayomi/suwayomi-webui"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t('global.language.title.weblate')}
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
value={i18nResources.includes(i18n.language as I18nResourceCode) ? i18n.language : 'en'}
|
||||
onChange={({ target: { value: language } }) => i18n.changeLanguage(language)}
|
||||
>
|
||||
{i18nResources.map((language) => (
|
||||
<MenuItem key={language} value={language}>
|
||||
{langCodeToName(language)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
<NumberSetting
|
||||
settingTitle={t('settings.label.manga_item_width')}
|
||||
settingValue={`px: ${itemWidth}`}
|
||||
value={itemWidth}
|
||||
defaultValue={DEFAULT_ITEM_WIDTH}
|
||||
minValue={100}
|
||||
maxValue={1000}
|
||||
stepSize={10}
|
||||
valueUnit="px"
|
||||
showSlider
|
||||
handleUpdate={setItemWidth}
|
||||
/>
|
||||
</List>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
127
src/screens/settings/appearance/Appearance.tsx
Normal file
127
src/screens/settings/appearance/Appearance.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* 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 { useContext, useEffect } from 'react';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Link from '@mui/material/Link';
|
||||
import { NavBarContext } from '@/components/context/NavbarContext.tsx';
|
||||
import { ThemeMode, ThemeModeContext } from '@/components/context/ThemeModeContext.tsx';
|
||||
import { Select } from '@/components/atoms/Select.tsx';
|
||||
import { MediaQuery } from '@/lib/ui/MediaQuery.tsx';
|
||||
import { NumberSetting } from '@/components/settings/NumberSetting.tsx';
|
||||
import { useLocalStorage } from '@/util/useStorage.tsx';
|
||||
import { I18nResourceCode, i18nResources } from '@/i18n';
|
||||
import { langCodeToName } from '@/util/language.tsx';
|
||||
import { getTheme } from '@/lib/ui/AppThemes.ts';
|
||||
import { ThemeList } from '@/screens/settings/appearance/theme/ThemeList.tsx';
|
||||
|
||||
export const Appearance = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { themeMode, setThemeMode, pureBlackMode, setPureBlackMode, appTheme } = useContext(ThemeModeContext);
|
||||
const isDarkMode =
|
||||
getTheme(appTheme).muiTheme.palette?.mode === 'dark' || MediaQuery.getThemeMode() === ThemeMode.DARK;
|
||||
|
||||
const { setTitle, setAction } = useContext(NavBarContext);
|
||||
useEffect(() => {
|
||||
setTitle(t('settings.appearance.title'));
|
||||
setAction(null);
|
||||
|
||||
return () => {
|
||||
setTitle('');
|
||||
setAction(null);
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const DEFAULT_ITEM_WIDTH = 300;
|
||||
const [itemWidth, setItemWidth] = useLocalStorage<number>('ItemWidth', DEFAULT_ITEM_WIDTH);
|
||||
|
||||
return (
|
||||
<List
|
||||
subheader={
|
||||
<ListSubheader component="div" id="appearance-theme">
|
||||
{t('settings.appearance.theme.title')}
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItem>
|
||||
<ListItemText primary={t('settings.appearance.theme.device_theme')} />
|
||||
<Select<ThemeMode> value={themeMode} onChange={(e) => setThemeMode(e.target.value as ThemeMode)}>
|
||||
<MenuItem key={ThemeMode.SYSTEM} value={ThemeMode.SYSTEM}>
|
||||
System
|
||||
</MenuItem>
|
||||
<MenuItem key={ThemeMode.DARK} value={ThemeMode.DARK}>
|
||||
Dark
|
||||
</MenuItem>
|
||||
<MenuItem key={ThemeMode.LIGHT} value={ThemeMode.LIGHT}>
|
||||
Light
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</ListItem>
|
||||
<ThemeList />
|
||||
{isDarkMode && (
|
||||
<ListItem>
|
||||
<ListItemText primary={t('settings.appearance.theme.pure_black_mode')} />
|
||||
<Switch checked={pureBlackMode} onChange={(_, enabled) => setPureBlackMode(enabled)} />
|
||||
</ListItem>
|
||||
)}
|
||||
<List
|
||||
subheader={
|
||||
<ListSubheader component="div" id="appearance-theme">
|
||||
{t('global.label.display')}
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={t('global.language.label.language')}
|
||||
secondary={
|
||||
<>
|
||||
<span>{t('settings.label.language_description')} </span>
|
||||
<Link
|
||||
href="https://hosted.weblate.org/projects/suwayomi/suwayomi-webui"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t('global.language.title.weblate')}
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
value={i18nResources.includes(i18n.language as I18nResourceCode) ? i18n.language : 'en'}
|
||||
onChange={({ target: { value: language } }) => i18n.changeLanguage(language)}
|
||||
>
|
||||
{i18nResources.map((language) => (
|
||||
<MenuItem key={language} value={language}>
|
||||
{langCodeToName(language)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
<NumberSetting
|
||||
settingTitle={t('settings.label.manga_item_width')}
|
||||
settingValue={`px: ${itemWidth}`}
|
||||
value={itemWidth}
|
||||
defaultValue={DEFAULT_ITEM_WIDTH}
|
||||
minValue={100}
|
||||
maxValue={1000}
|
||||
stepSize={10}
|
||||
valueUnit="px"
|
||||
showSlider
|
||||
handleUpdate={setItemWidth}
|
||||
/>
|
||||
</List>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
38
src/screens/settings/appearance/theme/CreateThemeButton.tsx
Normal file
38
src/screens/settings/appearance/theme/CreateThemeButton.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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 Typography from '@mui/material/Typography';
|
||||
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 { ThemeCreationDialog } from '@/screens/settings/appearance/theme/CreateThemeDialog.tsx';
|
||||
|
||||
export const CreateThemeButton = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const popupState = usePopupState({ variant: 'popover', popupId: 'theme-creation-dialog' });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<Button
|
||||
sx={{ minWidth: '150px', height: '225px', m: 1, mb: 0 }}
|
||||
variant="contained"
|
||||
size="large"
|
||||
{...bindTrigger(popupState)}
|
||||
>
|
||||
<AddCircleIcon />
|
||||
</Button>
|
||||
<Typography>{t('settings.appearance.theme.create.title')}</Typography>
|
||||
</Stack>
|
||||
{popupState.isOpen && <ThemeCreationDialog bindDialogProps={bindDialog(popupState)} mode="create" />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
220
src/screens/settings/appearance/theme/CreateThemeDialog.tsx
Normal file
220
src/screens/settings/appearance/theme/CreateThemeDialog.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* 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 { jsonSaveParse } from '@/util/HelperFunctions.ts';
|
||||
import { AppTheme, isThemeNameUnique } from '@/lib/ui/AppThemes.ts';
|
||||
import {
|
||||
createUpdateMetadataServerSettings,
|
||||
useMetadataServerSettings,
|
||||
} from '@/lib/metadata/metadataServerSettings.ts';
|
||||
import { MetadataThemeSettings, TranslationKey } from '@/typings.ts';
|
||||
import { makeToast } from '@/components/util/Toast.tsx';
|
||||
import { EmptyView } from '@/components/util/EmptyView.tsx';
|
||||
import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder.tsx';
|
||||
import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts';
|
||||
|
||||
const baseCustomTheme: AppTheme = {
|
||||
id: '',
|
||||
isCustom: true,
|
||||
getName: () => '',
|
||||
muiTheme: {
|
||||
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={error.message}
|
||||
retry={() => refetch().catch(defaultPromiseErrorHandler('ThemeCreationDialog::refetch'))}
|
||||
/>
|
||||
)}
|
||||
{!error && (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Typography>
|
||||
<Trans i18nKey="settings.appearance.theme.create.dialog.info.creating_theme">
|
||||
Create a custom theme with the{' '}
|
||||
<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>
|
||||
<Typography>{t('settings.appearance.theme.create.dialog.info.theme_mode_lock')}</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}
|
||||
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(() =>
|
||||
makeToast(
|
||||
t(dialogModeToTranslationKey[mode].failure, { theme: theme.getName() }),
|
||||
'error',
|
||||
),
|
||||
)
|
||||
.finally(() => setIsCreating(false));
|
||||
}}
|
||||
color="primary"
|
||||
>
|
||||
{t(dialogModeToTranslationKey[mode].button)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
92
src/screens/settings/appearance/theme/ThemeList.tsx
Normal file
92
src/screens/settings/appearance/theme/ThemeList.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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 '@/lib/ui/AppThemes.ts';
|
||||
import {
|
||||
createUpdateMetadataServerSettings,
|
||||
useMetadataServerSettings,
|
||||
} from '@/lib/metadata/metadataServerSettings.ts';
|
||||
import { MetadataThemeSettings } from '@/typings.ts';
|
||||
import { makeToast } from '@/components/util/Toast.tsx';
|
||||
import { EmptyView } from '@/components/util/EmptyView.tsx';
|
||||
import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder.tsx';
|
||||
import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts';
|
||||
import { CreateThemeButton } from '@/screens/settings/appearance/theme/CreateThemeButton.tsx';
|
||||
import { ThemePreview } from '@/screens/settings/appearance/theme/ThemePreview.tsx';
|
||||
|
||||
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={error.message}
|
||||
retry={() => refetch().catch(defaultPromiseErrorHandler('ThemeList::refetch'))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack sx={{ flexDirection: 'row', flexWrap: 'no-wrap', overflowX: 'auto', gap: 1, mx: 1 }}>
|
||||
<CreateThemeButton />
|
||||
{allThemes.map((theme) => (
|
||||
<ThemePreview
|
||||
key={theme.id}
|
||||
theme={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(() =>
|
||||
makeToast(
|
||||
t('settings.appearance.theme.delete.failure', { theme: theme.getName() }),
|
||||
'error',
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
185
src/screens/settings/appearance/theme/ThemePreview.tsx
Normal file
185
src/screens/settings/appearance/theme/ThemePreview.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* 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 { useContext, useMemo } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { styled, ThemeProvider } 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 Tooltip from '@mui/material/Tooltip';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import { bindDialog, usePopupState } from 'material-ui-popup-state/hooks';
|
||||
import { ThemeModeContext } from '@/components/context/ThemeModeContext.tsx';
|
||||
import { AppTheme } from '@/lib/ui/AppThemes.ts';
|
||||
import { createTheme } from '@/theme.ts';
|
||||
import { ThemeCreationDialog } from '@/screens/settings/appearance/theme/CreateThemeDialog.tsx';
|
||||
|
||||
const ThemePreviewBadge = styled(Box)(({ theme }) => ({
|
||||
width: '15px',
|
||||
height: '20px',
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
}));
|
||||
|
||||
export const ThemePreview = ({ theme, onDelete }: { theme: AppTheme; onDelete: () => void }) => {
|
||||
const { getName } = theme;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { themeMode, setAppTheme, appTheme, pureBlackMode } = useContext(ThemeModeContext);
|
||||
|
||||
const popupState = usePopupState({ variant: 'popover', popupId: `theme-edit-dialog-${theme.id}` });
|
||||
|
||||
const isSelected = theme.id === appTheme;
|
||||
|
||||
const muiTheme = useMemo(() => createTheme(themeMode, theme, pureBlackMode), [theme, themeMode, pureBlackMode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
sx={{
|
||||
m: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minWidth: '150px',
|
||||
maxWidth: '150px',
|
||||
gap: 1,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<ThemeProvider theme={muiTheme}>
|
||||
<Card
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '225px',
|
||||
outline: '4px solid',
|
||||
outlineColor: isSelected
|
||||
? muiTheme.palette.primary.light
|
||||
: muiTheme.palette.background.paper,
|
||||
borderRadius: 1,
|
||||
p: 0,
|
||||
}}
|
||||
>
|
||||
<CardActionArea
|
||||
sx={{ height: '100%', backgroundColor: 'background.default' }}
|
||||
onClick={() => setAppTheme(theme.id)}
|
||||
>
|
||||
<Stack sx={{ height: '100%' }}>
|
||||
<Stack sx={{ height: '100%', gap: 2, p: 1 }}>
|
||||
<Stack
|
||||
sx={{
|
||||
maxHeight: '20px',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '65%',
|
||||
height: '20px',
|
||||
backgroundColor: 'primary.dark',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
<Stack sx={{ height: '100%', alignItems: 'center', gap: 1 }}>
|
||||
{isSelected && (
|
||||
<CheckCircleIcon
|
||||
sx={{
|
||||
visibility: isSelected ? 'visible' : 'hidden',
|
||||
color: 'primary.light',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isSelected && theme.isCustom && (
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
component="div"
|
||||
size="small"
|
||||
>
|
||||
<Tooltip title={t('global.button.delete')}>
|
||||
<DeleteIcon />
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
)}
|
||||
{theme.isCustom && (
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
popupState.open();
|
||||
}}
|
||||
component="div"
|
||||
size="small"
|
||||
>
|
||||
<Tooltip title={t('global.button.edit')}>
|
||||
<EditIcon />
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack
|
||||
sx={{
|
||||
flexDirection: 'row',
|
||||
width: '55%',
|
||||
height: '65%',
|
||||
backgroundColor: 'background.paper',
|
||||
borderRadius: 1,
|
||||
p: 1,
|
||||
}}
|
||||
>
|
||||
<ThemePreviewBadge sx={{ backgroundColor: 'primary.main' }} />
|
||||
<ThemePreviewBadge sx={{ backgroundColor: 'secondary.main' }} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack
|
||||
sx={{
|
||||
height: '80px',
|
||||
backgroundColor: muiTheme.palette.background.paper,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'primary.main',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 0.75,
|
||||
height: '20px',
|
||||
borderRadius: 1,
|
||||
backgroundColor: 'primary.light',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</ThemeProvider>
|
||||
<Typography>{getName()}</Typography>
|
||||
</Stack>
|
||||
<ThemeCreationDialog bindDialogProps={bindDialog(popupState)} mode="edit" appTheme={theme} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user