Use custom themes

This commit is contained in:
schroda
2024-09-05 12:20:21 +02:00
parent ce3de56fcd
commit 12d12ccc48
16 changed files with 869 additions and 357 deletions

View File

@@ -49,6 +49,7 @@
"i18next": "23.14.0", "i18next": "23.14.0",
"i18next-browser-languagedetector": "8.0.0", "i18next-browser-languagedetector": "8.0.0",
"i18next-http-backend": "2.6.1", "i18next-http-backend": "2.6.1",
"jsonrepair": "3.8.0",
"material-ui-popup-state": "5.3.1", "material-ui-popup-state": "5.3.1",
"mui-nested-menu": "3.4.0", "mui-nested-menu": "3.4.0",
"p-limit": "6.1.0", "p-limit": "6.1.0",

View File

@@ -328,6 +328,7 @@
"restore": "Restore", "restore": "Restore",
"resume": "Resume", "resume": "Resume",
"retry": "Retry", "retry": "Retry",
"save": "Save",
"select": "Select", "select": "Select",
"select_all": "Select all", "select_all": "Select all",
"set": "Set", "set": "Set",
@@ -818,19 +819,49 @@
} }
}, },
"appearance": { "appearance": {
"theme": {
"create": {
"action": "Creating theme…",
"dialog": {
"error": {
"invalid_json": "Invalid json",
"invalid_name": "The theme name must be unique and has a limit of 16 characters"
},
"info": {
"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 the \"mode\" is defined in the \"palette\" object, the theme will be locked into this mode and will not change regardless of the apps device theme"
},
"theme_name": "Name"
},
"failure": "Could not create theme",
"success": "Created theme",
"title": "Create theme"
},
"delete": {
"action": "Deleting theme \"{{theme}}\"…",
"failure": "Could not delete theme \"{{theme}}\"",
"success": "Deleted theme \"{{theme}}\""
},
"device_theme": "Device theme", "device_theme": "Device theme",
"edit": {
"action": "Saving theme changes…",
"failure": "Could not save theme changes",
"success": "Saved theme changes",
"title": "Edit theme"
},
"pure_black_mode": "Pure black dark mode", "pure_black_mode": "Pure black dark mode",
"theme": "Theme",
"themes": { "themes": {
"crimson": "Crimson", "crimson": "Crimson",
"dune": "Dune", "dune": "Dune",
"forest_dew": "Forest dew", "forest_dew": "Forest dew",
"lavendar": "Lavendar", "lavender": "Lavender",
"minty_miracles": "Minty Miracles", "minty_miracles": "Minty Miracles",
"mountain_sunset": "Mountain sunset", "mountain_sunset": "Mountain sunset",
"orange_juice": "Orange juice", "orange_juice": "Orange juice",
"rosegold": "Rosegold" "rosegold": "Rosegold"
}, },
"title": "Theme"
},
"title": "Appearance" "title": "Appearance"
}, },
"backup": { "backup": {

View File

@@ -46,7 +46,7 @@ const { DeviceSetting } = loadable(() => import('@/components/settings/DeviceSet
const { TrackingSettings } = loadable(() => import('@/screens/settings/TrackingSettings.tsx'), lazyLoadFallback); const { TrackingSettings } = loadable(() => import('@/screens/settings/TrackingSettings.tsx'), lazyLoadFallback);
const { TrackerOAuthLogin } = loadable(() => import('@/screens/TrackerOAuthLogin.tsx'), lazyLoadFallback); const { TrackerOAuthLogin } = loadable(() => import('@/screens/TrackerOAuthLogin.tsx'), lazyLoadFallback);
const { LibraryDuplicates } = loadable(() => import('@/screens/settings/LibraryDuplicates.tsx'), lazyLoadFallback); const { LibraryDuplicates } = loadable(() => import('@/screens/settings/LibraryDuplicates.tsx'), lazyLoadFallback);
const { Appearance } = loadable(() => import('@/screens/settings/Appearance.tsx'), lazyLoadFallback); const { Appearance } = loadable(() => import('@/screens/settings/appearance/Appearance.tsx'), lazyLoadFallback);
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
// Adds messages only in a dev environment // Adds messages only in a dev environment

View File

@@ -23,7 +23,8 @@ import { NavBarContextProvider } from '@/components/navbar/NavBarContextProvider
import { LibraryOptionsContextProvider } from '@/components/library/LibraryOptionsProvider'; import { LibraryOptionsContextProvider } from '@/components/library/LibraryOptionsProvider';
import { ActiveDevice, DEFAULT_DEVICE, setActiveDevice } from '@/util/device.ts'; import { ActiveDevice, DEFAULT_DEVICE, setActiveDevice } from '@/util/device.ts';
import { MediaQuery } from '@/lib/ui/MediaQuery.tsx'; import { MediaQuery } from '@/lib/ui/MediaQuery.tsx';
import { AppThemes } from '@/lib/ui/AppThemes.ts'; import { AppThemes, getTheme } from '@/lib/ui/AppThemes.ts';
import { useMetadataServerSettings } from '@/lib/metadata/metadataServerSettings.ts';
interface Props { interface Props {
children: React.ReactNode; children: React.ReactNode;
@@ -50,6 +51,10 @@ export const AppContext: React.FC<Props> = ({ children }) => {
directionRef.current = currentDirection; directionRef.current = currentDirection;
} }
const {
settings: { customThemes },
} = useMetadataServerSettings();
const [systemThemeMode, setSystemThemeMode] = useState<ThemeMode>(MediaQuery.getSystemThemeMode()); const [systemThemeMode, setSystemThemeMode] = useState<ThemeMode>(MediaQuery.getSystemThemeMode());
useLayoutEffect(() => { useLayoutEffect(() => {
const unsubscribe = MediaQuery.listenToSystemThemeChange(setSystemThemeMode); const unsubscribe = MediaQuery.listenToSystemThemeChange(setSystemThemeMode);
@@ -80,8 +85,8 @@ export const AppContext: React.FC<Props> = ({ children }) => {
); );
const theme = useMemo( const theme = useMemo(
() => createAndSetTheme(themeMode, appTheme, pureBlackMode, currentDirection), () => createAndSetTheme(themeMode, getTheme(appTheme, customThemes), pureBlackMode, currentDirection),
[themeMode, currentDirection, systemThemeMode, pureBlackMode, appTheme], [themeMode, currentDirection, systemThemeMode, pureBlackMode, appTheme, customThemes],
); );
setActiveDevice(activeDevice); setActiveDevice(activeDevice);

View File

@@ -57,6 +57,9 @@ const GLOBAL_METADATA_KEYS: AppMetadataKeys[] = [
// sources // sources
'savedSearches', 'savedSearches',
// themes
'customThemes',
]; ];
/** /**

View File

@@ -12,6 +12,7 @@ import {
Metadata, Metadata,
MetadataServerSettingKeys, MetadataServerSettingKeys,
MetadataServerSettings, MetadataServerSettings,
MetadataThemeSettings,
} from '@/typings.ts'; } from '@/typings.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts';
import { convertFromGqlMeta, getMetadataFrom, requestUpdateServerMetadata } from '@/lib/metadata/metadata.ts'; import { convertFromGqlMeta, getMetadataFrom, requestUpdateServerMetadata } from '@/lib/metadata/metadata.ts';
@@ -50,6 +51,9 @@ export const getDefaultSettings = (): MetadataServerSettings => ({
// updates // updates
webUIInformAvailableUpdate: true, webUIInformAvailableUpdate: true,
serverInformAvailableUpdate: true, serverInformAvailableUpdate: true,
// themes
customThemes: {},
}); });
export const convertSettingsToMetadata = ( export const convertSettingsToMetadata = (
@@ -57,6 +61,7 @@ export const convertSettingsToMetadata = (
): Metadata<string, AllowedMetadataValueTypes> => ({ ): Metadata<string, AllowedMetadataValueTypes> => ({
...settings, ...settings,
devices: JSON.stringify(settings.devices), devices: JSON.stringify(settings.devices),
customThemes: JSON.stringify(settings.customThemes),
}); });
export const convertMetadataToSettings = ( export const convertMetadataToSettings = (
@@ -66,6 +71,9 @@ export const convertMetadataToSettings = (
...getDefaultSettings(), ...getDefaultSettings(),
...(metadata as unknown as MetadataServerSettings), ...(metadata as unknown as MetadataServerSettings),
devices: jsonSaveParse<string[]>((metadata.devices as string) ?? '') ?? getDefaultSettings().devices, devices: jsonSaveParse<string[]>((metadata.devices as string) ?? '') ?? getDefaultSettings().devices,
customThemes:
jsonSaveParse<MetadataThemeSettings['customThemes']>((metadata.customThemes as string) ?? '') ??
getDefaultSettings().customThemes,
}) satisfies MetadataServerSettings; }) satisfies MetadataServerSettings;
const getMetadataServerSettingsWithDefaultFallback = ( const getMetadataServerSettingsWithDefaultFallback = (

View File

@@ -6,14 +6,17 @@
* 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 { PaletteOptions } from '@mui/material/styles'; import { ThemeOptions } from '@mui/material/styles';
import { t as translate } from 'i18next'; import { t as translate } from 'i18next';
import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts';
type Theme = { getName: () => string; palette: PaletteOptions }; type Theme = { isCustom: boolean; getName: () => string; muiTheme: ThemeOptions };
const themes = { const themes = {
default: { default: {
isCustom: false,
getName: () => translate('global.label.default'), getName: () => translate('global.label.default'),
muiTheme: {
palette: { palette: {
primary: { primary: {
main: '#5b74ef', main: '#5b74ef',
@@ -23,8 +26,11 @@ const themes = {
}, },
}, },
}, },
},
lavender: { lavender: {
getName: () => translate('settings.appearance.themes.lavendar'), isCustom: false,
getName: () => translate('settings.appearance.theme.themes.lavender'),
muiTheme: {
palette: { palette: {
primary: { primary: {
main: '#5c58dc', main: '#5c58dc',
@@ -34,8 +40,11 @@ const themes = {
}, },
}, },
}, },
},
dune: { dune: {
getName: () => translate('settings.appearance.themes.dune'), isCustom: false,
getName: () => translate('settings.appearance.theme.themes.dune'),
muiTheme: {
palette: { palette: {
primary: { primary: {
main: '#897869', main: '#897869',
@@ -45,8 +54,11 @@ const themes = {
}, },
}, },
}, },
},
rosegold: { rosegold: {
getName: () => translate('settings.appearance.themes.rosegold'), isCustom: false,
getName: () => translate('settings.appearance.theme.themes.rosegold'),
muiTheme: {
palette: { palette: {
primary: { primary: {
main: '#f1a886', main: '#f1a886',
@@ -56,8 +68,11 @@ const themes = {
}, },
}, },
}, },
forestDew: { },
getName: () => translate('settings.appearance.themes.forest_dew'), 'forest dew': {
isCustom: false,
getName: () => translate('settings.appearance.theme.themes.forest_dew'),
muiTheme: {
palette: { palette: {
primary: { primary: {
main: '#53a584', main: '#53a584',
@@ -67,8 +82,11 @@ const themes = {
}, },
}, },
}, },
mountainSunset: { },
getName: () => translate('settings.appearance.themes.mountain_sunset'), 'montain sunset': {
isCustom: false,
getName: () => translate('settings.appearance.theme.themes.mountain_sunset'),
muiTheme: {
palette: { palette: {
primary: { primary: {
main: '#c55a77', main: '#c55a77',
@@ -78,8 +96,11 @@ const themes = {
}, },
}, },
}, },
},
crimson: { crimson: {
getName: () => translate('settings.appearance.themes.crimson'), isCustom: false,
getName: () => translate('settings.appearance.theme.themes.crimson'),
muiTheme: {
palette: { palette: {
primary: { primary: {
main: '#ff0033', main: '#ff0033',
@@ -89,8 +110,11 @@ const themes = {
}, },
}, },
}, },
mintyMiracles: { },
getName: () => translate('settings.appearance.themes.minty_miracles'), 'minty miracles': {
isCustom: false,
getName: () => translate('settings.appearance.theme.themes.minty_miracles'),
muiTheme: {
palette: { palette: {
primary: { primary: {
main: '#9defc3', main: '#9defc3',
@@ -100,8 +124,11 @@ const themes = {
}, },
}, },
}, },
orangeJuice: { },
getName: () => translate('settings.appearance.themes.orange_juice'), 'orange juice': {
isCustom: false,
getName: () => translate('settings.appearance.theme.themes.orange_juice'),
muiTheme: {
palette: { palette: {
primary: { primary: {
main: '#ffb546', main: '#ffb546',
@@ -111,15 +138,33 @@ const themes = {
}, },
}, },
}, },
},
} as const satisfies Record<string, Theme>; } as const satisfies Record<string, Theme>;
export type AppThemes = keyof typeof themes; export type AppThemes = keyof typeof themes | string;
export type AppTheme = Theme & { id: AppThemes }; export type AppTheme = Theme & { id: AppThemes; isCustom: boolean };
export const appThemes = (Object.entries(themes) as [AppThemes, Theme][]).map(([id, theme]) => ({ export const appThemes = (Object.entries(themes) as [AppThemes, Theme][]).map(([id, theme]) => ({
id, id,
...theme, ...theme,
})) satisfies AppTheme[]; })) satisfies AppTheme[];
export const getTheme = (id: AppThemes): AppTheme => ({ id, ...themes[id] }); export const getTheme = (id: AppThemes, customThemes: Record<string, AppTheme> = {}): AppTheme => {
try {
const allThemes = { ...themes, ...customThemes };
const theme = (allThemes[id as keyof typeof themes] as AppTheme) ?? themes.default;
return {
// @ts-ignore - custom themes do not have a "getName" function
getName: () => id,
...theme,
};
} catch (e) {
defaultPromiseErrorHandler('getTheme')(e);
}
return { id, ...themes[id as keyof typeof themes] };
};
export const isThemeNameUnique = (id: string, customThemes: Record<string, AppTheme>): boolean =>
Object.keys({ ...themes, ...customThemes }).every((themeId) => themeId.toLowerCase() !== id.toLowerCase());

View File

@@ -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>
);
};

View 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>
);
};

View 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" />}
</>
);
};

View 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 &quot;themeOptions&quot; from &quot;{'{'}&quot; to &quot;{'}'}
&quot; and paste it into the &quot;theme&quot; 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>
);
};

View 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>
);
};

View 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} />
</>
);
};

View File

@@ -17,28 +17,32 @@ import {
} from '@mui/material/styles'; } from '@mui/material/styles';
import { ThemeMode } from '@/components/context/ThemeModeContext.tsx'; import { ThemeMode } from '@/components/context/ThemeModeContext.tsx';
import { MediaQuery } from '@/lib/ui/MediaQuery.tsx'; import { MediaQuery } from '@/lib/ui/MediaQuery.tsx';
import { AppThemes, getTheme } from '@/lib/ui/AppThemes.ts'; import { AppTheme } from '@/lib/ui/AppThemes.ts';
export const SCROLLBAR_WIDTH = 14; export const SCROLLBAR_WIDTH = 14;
export const createTheme = ( export const createTheme = (
themeMode: ThemeMode, themeMode: ThemeMode,
appThemeId: AppThemes, appTheme: AppTheme,
pureBlackMode: boolean = false, pureBlackMode: boolean = false,
direction: Direction = 'ltr', direction: Direction = 'ltr',
) => { ) => {
const appTheme = getTheme(appThemeId);
const systemMode = MediaQuery.getSystemThemeMode(); const systemMode = MediaQuery.getSystemThemeMode();
const mode = themeMode === ThemeMode.SYSTEM ? systemMode : themeMode;
const isStaticThemeMode = !!appTheme.muiTheme.palette?.mode;
const appThemeMode = appTheme.muiTheme.palette?.mode === '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 baseTheme = createMuiTheme({
direction, direction,
...appTheme.muiTheme,
palette: { palette: {
mode, mode,
...appTheme.palette, ...(appTheme.muiTheme.palette ?? {}),
}, },
}); });
@@ -55,7 +59,11 @@ export const createTheme = (
default: lighten(baseTheme.palette.primary.dark, 0.85), default: lighten(baseTheme.palette.primary.dark, 0.85),
}; };
const backgroundThemeMode = isDarkMode ? backgroundDark : backgroundLight; const backgroundThemeMode = isDarkMode ? backgroundDark : backgroundLight;
const background = setPureBlackMode ? backgroundTrueBlack : backgroundThemeMode; const automaticBackground = setPureBlackMode ? backgroundTrueBlack : backgroundThemeMode;
const appThemeBackground = appTheme.muiTheme.palette?.background;
const requiresAutomaticBackground = setPureBlackMode || !appThemeBackground;
const background = requiresAutomaticBackground ? automaticBackground : appThemeBackground;
const colorTheme = createMuiTheme(baseTheme, { const colorTheme = createMuiTheme(baseTheme, {
palette: { palette: {

View File

@@ -20,6 +20,7 @@ import {
SourcePreferenceChangeInput, SourcePreferenceChangeInput,
TrackerType, TrackerType,
} from '@/lib/graphql/generated/graphql.ts'; } from '@/lib/graphql/generated/graphql.ts';
import { AppTheme } from '@/lib/ui/AppThemes.ts';
type GenericLocation<State = any> = Omit<Location, 'state'> & { state?: State }; type GenericLocation<State = any> = Omit<Location, 'state'> & { state?: State };
@@ -156,13 +157,18 @@ export type MetadataUpdateSettings = {
serverInformAvailableUpdate: boolean; serverInformAvailableUpdate: boolean;
}; };
export type MetadataThemeSettings = {
customThemes: Record<string, AppTheme>;
};
export type MetadataServerSettings = MetadataDownloadSettings & export type MetadataServerSettings = MetadataDownloadSettings &
MetadataLibrarySettings & MetadataLibrarySettings &
MetadataClientSettings & MetadataClientSettings &
MetadataMigrationSettings & MetadataMigrationSettings &
MetadataBrowseSettings & MetadataBrowseSettings &
MetadataTrackingSettings & MetadataTrackingSettings &
MetadataUpdateSettings; MetadataUpdateSettings &
MetadataThemeSettings;
export interface ISearchSettings { export interface ISearchSettings {
ignoreFilters: boolean; ignoreFilters: boolean;

View File

@@ -5862,6 +5862,11 @@ jsonify@^0.0.1:
resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978" resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978"
integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg== integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==
jsonrepair@3.8.0:
version "3.8.0"
resolved "https://registry.yarnpkg.com/jsonrepair/-/jsonrepair-3.8.0.tgz#33a1b0d3630c452e9945ef07d760469cdfad8823"
integrity sha512-89lrxpwp+IEcJ6kwglF0HH3Tl17J08JEpYfXnvvjdp4zV4rjSoGu2NdQHxBs7yTOk3ETjTn9du48pBy8iBqj1w==
"jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.3.5: "jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.3.5:
version "3.3.5" version "3.3.5"
resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz#4766bd05a8e2a11af222becd19e15575e52a853a" resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz#4766bd05a8e2a11af222becd19e15575e52a853a"