Add themes

This commit is contained in:
schroda
2024-09-05 02:35:08 +02:00
parent cba2617190
commit 97b4befbb1
8 changed files with 279 additions and 17 deletions

View File

@@ -386,6 +386,7 @@
"client": "Client", "client": "Client",
"close": "Close", "close": "Close",
"copied": "Copied", "copied": "Copied",
"default": "Default",
"disabled": "Disabled", "disabled": "Disabled",
"discord": "Discord", "discord": "Discord",
"display": "Display", "display": "Display",
@@ -820,6 +821,16 @@
"device_theme": "Device theme", "device_theme": "Device theme",
"pure_black_mode": "Pure black dark mode", "pure_black_mode": "Pure black dark mode",
"theme": "Theme", "theme": "Theme",
"themes": {
"crimson": "Crimson",
"dune": "Dune",
"forest_dew": "Forest dew",
"lavendar": "Lavendar",
"minty_miracles": "Minty Miracles",
"mountain_sunset": "Mountain sunset",
"orange_juice": "Orange juice",
"rosegold": "Rosegold"
},
"title": "Appearance" "title": "Appearance"
}, },
"backup": { "backup": {

View File

@@ -16,13 +16,14 @@ import { CacheProvider, EmotionCache } from '@emotion/react';
import createCache from '@emotion/cache'; import createCache from '@emotion/cache';
import { prefixer } from 'stylis'; import { prefixer } from 'stylis';
import rtlPlugin from 'stylis-plugin-rtl'; import rtlPlugin from 'stylis-plugin-rtl';
import { createTheme } from '@/theme'; import { createAndSetTheme } from '@/theme';
import { useLocalStorage } from '@/util/useStorage.tsx'; import { useLocalStorage } from '@/util/useStorage.tsx';
import { ThemeMode, ThemeModeContext } from '@/components/context/ThemeModeContext.tsx'; import { ThemeMode, ThemeModeContext } from '@/components/context/ThemeModeContext.tsx';
import { NavBarContextProvider } from '@/components/navbar/NavBarContextProvider'; 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';
interface Props { interface Props {
children: React.ReactNode; children: React.ReactNode;
@@ -56,18 +57,21 @@ export const AppContext: React.FC<Props> = ({ children }) => {
return () => unsubscribe(); return () => unsubscribe();
}, []); }, []);
const [appTheme, setAppTheme] = useLocalStorage<AppThemes>('appTheme', 'default');
const [themeMode, setThemeMode] = useLocalStorage<ThemeMode>('themeMode', ThemeMode.SYSTEM); const [themeMode, setThemeMode] = useLocalStorage<ThemeMode>('themeMode', ThemeMode.SYSTEM);
const [pureBlackMode, setPureBlackMode] = useLocalStorage<boolean>('pureBlackMode', false); const [pureBlackMode, setPureBlackMode] = useLocalStorage<boolean>('pureBlackMode', false);
const [activeDevice, setActiveDeviceContext] = useLocalStorage('activeDevice', DEFAULT_DEVICE); const [activeDevice, setActiveDeviceContext] = useLocalStorage('activeDevice', DEFAULT_DEVICE);
const darkThemeContext = useMemo( const darkThemeContext = useMemo(
() => ({ () => ({
appTheme,
setAppTheme,
themeMode, themeMode,
setThemeMode, setThemeMode,
pureBlackMode, pureBlackMode,
setPureBlackMode, setPureBlackMode,
}), }),
[themeMode, pureBlackMode], [themeMode, pureBlackMode, appTheme],
); );
const activeDeviceContext = useMemo( const activeDeviceContext = useMemo(
@@ -76,8 +80,8 @@ export const AppContext: React.FC<Props> = ({ children }) => {
); );
const theme = useMemo( const theme = useMemo(
() => createTheme(themeMode, currentDirection, undefined, pureBlackMode), () => createAndSetTheme(themeMode, appTheme, pureBlackMode, currentDirection),
[themeMode, currentDirection, systemThemeMode, pureBlackMode], [themeMode, currentDirection, systemThemeMode, pureBlackMode, appTheme],
); );
setActiveDevice(activeDevice); setActiveDevice(activeDevice);

View File

@@ -7,6 +7,7 @@
*/ */
import React from 'react'; import React from 'react';
import { AppThemes } from '@/lib/ui/AppThemes.ts';
export enum ThemeMode { export enum ThemeMode {
SYSTEM = 'system', SYSTEM = 'system',
@@ -15,6 +16,8 @@ export enum ThemeMode {
} }
type ContextType = { type ContextType = {
appTheme: AppThemes;
setAppTheme: React.Dispatch<React.SetStateAction<AppThemes>>;
themeMode: ThemeMode; themeMode: ThemeMode;
setThemeMode: React.Dispatch<React.SetStateAction<ThemeMode>>; setThemeMode: React.Dispatch<React.SetStateAction<ThemeMode>>;
pureBlackMode: boolean; pureBlackMode: boolean;
@@ -22,6 +25,8 @@ type ContextType = {
}; };
export const ThemeModeContext = React.createContext<ContextType>({ export const ThemeModeContext = React.createContext<ContextType>({
appTheme: 'default',
setAppTheme: (): void => {},
themeMode: ThemeMode.SYSTEM, themeMode: ThemeMode.SYSTEM,
setThemeMode: (): void => {}, setThemeMode: (): void => {},
pureBlackMode: false, pureBlackMode: false,

View File

@@ -74,18 +74,19 @@ export const MangaBadges = ({
{inLibraryIndicator && isInLibrary && ( {inLibraryIndicator && isInLibrary && (
<Typography <Typography
className="source-manga-library-state-indicator" className="source-manga-library-state-indicator"
sx={{ backgroundColor: 'primary.dark', p: 0.3 }} sx={{ backgroundColor: 'primary.dark', color: 'primary.contrastText', p: 0.3 }}
> >
{t('manga.button.in_library')} {t('manga.button.in_library')}
</Typography> </Typography>
)} )}
{((showUnreadBadge && mode === 'default') || mode === 'duplicate') && (unread ?? 0) > 0 && ( {((showUnreadBadge && mode === 'default') || mode === 'duplicate') && (unread ?? 0) > 0 && (
<Badge sx={{ backgroundColor: 'primary.dark' }}>{unread}</Badge> <Badge sx={{ backgroundColor: 'primary.main', color: 'primary.contrastText' }}>{unread}</Badge>
)} )}
{((showDownloadBadge && mode === 'default') || mode === 'duplicate') && (downloadCount ?? 0) > 0 && ( {((showDownloadBadge && mode === 'default') || mode === 'duplicate') && (downloadCount ?? 0) > 0 && (
<Badge <Badge
sx={{ sx={{
backgroundColor: 'primary.light', backgroundColor: 'secondary.main',
color: 'secondary.contrastText',
}} }}
> >
{downloadCount} {downloadCount}

View File

@@ -156,7 +156,7 @@ const Thumbnail = ({ manga }: { manga: Partial<MangaThumbnailInfo> }) => {
}, },
}} }}
> >
<OpenInFullIcon fontSize="large" /> <OpenInFullIcon fontSize="large" color="primary" />
</Stack> </Stack>
</Stack> </Stack>
<Modal {...bindPopover(popupState)} sx={{ outline: 0 }}> <Modal {...bindPopover(popupState)} sx={{ outline: 0 }}>

125
src/lib/ui/AppThemes.ts Normal file
View File

@@ -0,0 +1,125 @@
/*
* 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 { PaletteOptions } from '@mui/material/styles';
import { t as translate } from 'i18next';
type Theme = { getName: () => string; palette: PaletteOptions };
const themes = {
default: {
getName: () => translate('global.label.default'),
palette: {
primary: {
main: '#5b74ef',
},
secondary: {
main: '#efd65b',
},
},
},
lavender: {
getName: () => translate('settings.appearance.themes.lavendar'),
palette: {
primary: {
main: '#5c58dc',
},
secondary: {
main: '#d8dc58',
},
},
},
dune: {
getName: () => translate('settings.appearance.themes.dune'),
palette: {
primary: {
main: '#897869',
},
secondary: {
main: '#697a89',
},
},
},
rosegold: {
getName: () => translate('settings.appearance.themes.rosegold'),
palette: {
primary: {
main: '#f1a886',
},
secondary: {
main: '#86cff1',
},
},
},
forestDew: {
getName: () => translate('settings.appearance.themes.forest_dew'),
palette: {
primary: {
main: '#53a584',
},
secondary: {
main: '#a55374',
},
},
},
mountainSunset: {
getName: () => translate('settings.appearance.themes.mountain_sunset'),
palette: {
primary: {
main: '#c55a77',
},
secondary: {
main: '#5ac5a8',
},
},
},
crimson: {
getName: () => translate('settings.appearance.themes.crimson'),
palette: {
primary: {
main: '#ff0033',
},
secondary: {
main: '#00ffcc',
},
},
},
mintyMiracles: {
getName: () => translate('settings.appearance.themes.minty_miracles'),
palette: {
primary: {
main: '#9defc3',
},
secondary: {
main: '#ef9dc9',
},
},
},
orangeJuice: {
getName: () => translate('settings.appearance.themes.orange_juice'),
palette: {
primary: {
main: '#ffb546',
},
secondary: {
main: '#4690ff',
},
},
},
} as const satisfies Record<string, Theme>;
export type AppThemes = keyof typeof themes;
export type AppTheme = Theme & { id: AppThemes };
export const appThemes = (Object.entries(themes) as [AppThemes, Theme][]).map(([id, theme]) => ({
id,
...theme,
})) satisfies AppTheme[];
export const getTheme = (id: AppThemes): AppTheme => ({ id, ...themes[id] });

View File

@@ -7,7 +7,7 @@
*/ */
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useContext, useEffect } from 'react'; import { useContext, useEffect, useMemo } from 'react';
import List from '@mui/material/List'; import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem'; import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
@@ -15,6 +15,11 @@ import MenuItem from '@mui/material/MenuItem';
import ListSubheader from '@mui/material/ListSubheader'; import ListSubheader from '@mui/material/ListSubheader';
import Switch from '@mui/material/Switch'; import Switch from '@mui/material/Switch';
import Link from '@mui/material/Link'; import Link from '@mui/material/Link';
import 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 { NavBarContext } from '@/components/context/NavbarContext.tsx'; import { NavBarContext } from '@/components/context/NavbarContext.tsx';
import { ThemeMode, ThemeModeContext } from '@/components/context/ThemeModeContext.tsx'; import { ThemeMode, ThemeModeContext } from '@/components/context/ThemeModeContext.tsx';
import { Select } from '@/components/atoms/Select.tsx'; import { Select } from '@/components/atoms/Select.tsx';
@@ -23,6 +28,112 @@ import { NumberSetting } from '@/components/settings/NumberSetting.tsx';
import { useLocalStorage } from '@/util/useStorage.tsx'; import { useLocalStorage } from '@/util/useStorage.tsx';
import { I18nResourceCode, i18nResources } from '@/i18n'; import { I18nResourceCode, i18nResources } from '@/i18n';
import { langCodeToName } from '@/util/language.tsx'; import { langCodeToName } from '@/util/language.tsx';
import { AppTheme, appThemes } from '@/lib/ui/AppThemes.ts';
import { createTheme } from '@/theme.ts';
const ThemePreviewBage = 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}>
<Stack
sx={{
backgroundColor: 'background.default',
width: '100%',
height: '225px',
outline: '4px solid',
outlineColor: isSelected ? muiTheme.palette.primary.light : muiTheme.palette.background.paper,
borderRadius: 1,
}}
onClick={() => setAppTheme(theme.id)}
>
<Stack sx={{ height: '100%', gap: 2, p: 1 }}>
<Stack sx={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Box
sx={{ width: '65%', height: '20px', backgroundColor: 'primary.dark', borderRadius: 1 }}
/>
{isSelected && (
<CheckCircleIcon
sx={{ visibility: isSelected ? 'visible' : 'hidden', color: 'primary.light' }}
// color="primary"
/>
)}
</Stack>
<Stack
sx={{
flexDirection: 'row',
width: '55%',
height: '65%',
backgroundColor: 'background.paper',
borderRadius: 1,
p: 1,
}}
>
<ThemePreviewBage sx={{ backgroundColor: 'primary.main' }} />
<ThemePreviewBage 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>
</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 = () => { export const Appearance = () => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@@ -65,6 +176,7 @@ export const Appearance = () => {
</MenuItem> </MenuItem>
</Select> </Select>
</ListItem> </ListItem>
<ThemePicker />
{isDarkMode && ( {isDarkMode && (
<ListItem> <ListItem>
<ListItemText primary={t('settings.appearance.pure_black_mode')} /> <ListItemText primary={t('settings.appearance.pure_black_mode')} />

View File

@@ -17,17 +17,18 @@ 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';
export const SCROLLBAR_WIDTH = 14; export const SCROLLBAR_WIDTH = 14;
let theme: Theme;
export const getCurrentTheme = () => theme;
export const createTheme = ( export const createTheme = (
themeMode: ThemeMode, themeMode: ThemeMode,
direction: Direction = 'ltr', appThemeId: AppThemes,
color: string = '#5b74ef',
pureBlackMode: boolean = false, pureBlackMode: boolean = false,
direction: Direction = 'ltr',
) => { ) => {
const appTheme = getTheme(appThemeId);
const systemMode = MediaQuery.getSystemThemeMode(); const systemMode = MediaQuery.getSystemThemeMode();
const mode = themeMode === ThemeMode.SYSTEM ? systemMode : themeMode; const mode = themeMode === ThemeMode.SYSTEM ? systemMode : themeMode;
const isDarkMode = mode === ThemeMode.DARK; const isDarkMode = mode === ThemeMode.DARK;
@@ -37,9 +38,7 @@ export const createTheme = (
direction, direction,
palette: { palette: {
mode, mode,
primary: { ...appTheme.palette,
main: color,
},
}, },
}); });
@@ -90,8 +89,13 @@ export const createTheme = (
}, },
}); });
theme = responsiveFontSizes(suwayomiTheme); return responsiveFontSizes(suwayomiTheme);
};
let theme: Theme;
export const getCurrentTheme = () => theme;
export const createAndSetTheme = (...args: Parameters<typeof createTheme>) => {
theme = createTheme(...args);
return theme; return theme;
}; };