From 97b4befbb1b63429a5bbd28732b11469099c6891 Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Thu, 5 Sep 2024 02:35:08 +0200 Subject: [PATCH] Add themes --- public/locales/en.json | 11 ++ src/components/context/AppContext.tsx | 12 +- src/components/context/ThemeModeContext.tsx | 5 + src/components/manga/MangaBadges.tsx | 7 +- src/components/manga/MangaDetails.tsx | 2 +- src/lib/ui/AppThemes.ts | 125 ++++++++++++++++++++ src/screens/settings/Appearance.tsx | 114 +++++++++++++++++- src/theme.ts | 20 ++-- 8 files changed, 279 insertions(+), 17 deletions(-) create mode 100644 src/lib/ui/AppThemes.ts diff --git a/public/locales/en.json b/public/locales/en.json index 808f70e0..b8f6897d 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -386,6 +386,7 @@ "client": "Client", "close": "Close", "copied": "Copied", + "default": "Default", "disabled": "Disabled", "discord": "Discord", "display": "Display", @@ -820,6 +821,16 @@ "device_theme": "Device theme", "pure_black_mode": "Pure black dark mode", "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" }, "backup": { diff --git a/src/components/context/AppContext.tsx b/src/components/context/AppContext.tsx index c387d3de..51ef3a82 100644 --- a/src/components/context/AppContext.tsx +++ b/src/components/context/AppContext.tsx @@ -16,13 +16,14 @@ import { CacheProvider, EmotionCache } from '@emotion/react'; import createCache from '@emotion/cache'; import { prefixer } from 'stylis'; import rtlPlugin from 'stylis-plugin-rtl'; -import { createTheme } from '@/theme'; +import { createAndSetTheme } from '@/theme'; import { useLocalStorage } from '@/util/useStorage.tsx'; import { ThemeMode, ThemeModeContext } from '@/components/context/ThemeModeContext.tsx'; import { NavBarContextProvider } from '@/components/navbar/NavBarContextProvider'; import { LibraryOptionsContextProvider } from '@/components/library/LibraryOptionsProvider'; import { ActiveDevice, DEFAULT_DEVICE, setActiveDevice } from '@/util/device.ts'; import { MediaQuery } from '@/lib/ui/MediaQuery.tsx'; +import { AppThemes } from '@/lib/ui/AppThemes.ts'; interface Props { children: React.ReactNode; @@ -56,18 +57,21 @@ export const AppContext: React.FC = ({ children }) => { return () => unsubscribe(); }, []); + const [appTheme, setAppTheme] = useLocalStorage('appTheme', 'default'); const [themeMode, setThemeMode] = useLocalStorage('themeMode', ThemeMode.SYSTEM); const [pureBlackMode, setPureBlackMode] = useLocalStorage('pureBlackMode', false); const [activeDevice, setActiveDeviceContext] = useLocalStorage('activeDevice', DEFAULT_DEVICE); const darkThemeContext = useMemo( () => ({ + appTheme, + setAppTheme, themeMode, setThemeMode, pureBlackMode, setPureBlackMode, }), - [themeMode, pureBlackMode], + [themeMode, pureBlackMode, appTheme], ); const activeDeviceContext = useMemo( @@ -76,8 +80,8 @@ export const AppContext: React.FC = ({ children }) => { ); const theme = useMemo( - () => createTheme(themeMode, currentDirection, undefined, pureBlackMode), - [themeMode, currentDirection, systemThemeMode, pureBlackMode], + () => createAndSetTheme(themeMode, appTheme, pureBlackMode, currentDirection), + [themeMode, currentDirection, systemThemeMode, pureBlackMode, appTheme], ); setActiveDevice(activeDevice); diff --git a/src/components/context/ThemeModeContext.tsx b/src/components/context/ThemeModeContext.tsx index bf3bec20..4b6fe96f 100644 --- a/src/components/context/ThemeModeContext.tsx +++ b/src/components/context/ThemeModeContext.tsx @@ -7,6 +7,7 @@ */ import React from 'react'; +import { AppThemes } from '@/lib/ui/AppThemes.ts'; export enum ThemeMode { SYSTEM = 'system', @@ -15,6 +16,8 @@ export enum ThemeMode { } type ContextType = { + appTheme: AppThemes; + setAppTheme: React.Dispatch>; themeMode: ThemeMode; setThemeMode: React.Dispatch>; pureBlackMode: boolean; @@ -22,6 +25,8 @@ type ContextType = { }; export const ThemeModeContext = React.createContext({ + appTheme: 'default', + setAppTheme: (): void => {}, themeMode: ThemeMode.SYSTEM, setThemeMode: (): void => {}, pureBlackMode: false, diff --git a/src/components/manga/MangaBadges.tsx b/src/components/manga/MangaBadges.tsx index 831265c6..b2cff63a 100644 --- a/src/components/manga/MangaBadges.tsx +++ b/src/components/manga/MangaBadges.tsx @@ -74,18 +74,19 @@ export const MangaBadges = ({ {inLibraryIndicator && isInLibrary && ( {t('manga.button.in_library')} )} {((showUnreadBadge && mode === 'default') || mode === 'duplicate') && (unread ?? 0) > 0 && ( - {unread} + {unread} )} {((showDownloadBadge && mode === 'default') || mode === 'duplicate') && (downloadCount ?? 0) > 0 && ( {downloadCount} diff --git a/src/components/manga/MangaDetails.tsx b/src/components/manga/MangaDetails.tsx index 5be25ab9..a8b04894 100644 --- a/src/components/manga/MangaDetails.tsx +++ b/src/components/manga/MangaDetails.tsx @@ -156,7 +156,7 @@ const Thumbnail = ({ manga }: { manga: Partial }) => { }, }} > - + diff --git a/src/lib/ui/AppThemes.ts b/src/lib/ui/AppThemes.ts new file mode 100644 index 00000000..703cb1d4 --- /dev/null +++ b/src/lib/ui/AppThemes.ts @@ -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; + +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] }); diff --git a/src/screens/settings/Appearance.tsx b/src/screens/settings/Appearance.tsx index 19033f88..7baf2e0c 100644 --- a/src/screens/settings/Appearance.tsx +++ b/src/screens/settings/Appearance.tsx @@ -7,7 +7,7 @@ */ import { useTranslation } from 'react-i18next'; -import { useContext, useEffect } from 'react'; +import { useContext, useEffect, useMemo } from 'react'; import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; import ListItemText from '@mui/material/ListItemText'; @@ -15,6 +15,11 @@ 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 { NavBarContext } from '@/components/context/NavbarContext.tsx'; import { ThemeMode, ThemeModeContext } from '@/components/context/ThemeModeContext.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 { I18nResourceCode, i18nResources } from '@/i18n'; 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 ( + + + setAppTheme(theme.id)} + > + + + + {isSelected && ( + + )} + + + + + + + + + + + + + {getName()} + + ); +}; + +const ThemePicker = () => ( + + {appThemes.map((theme) => ( + + ))} + +); export const Appearance = () => { const { t, i18n } = useTranslation(); @@ -65,6 +176,7 @@ export const Appearance = () => { + {isDarkMode && ( diff --git a/src/theme.ts b/src/theme.ts index 0f49bd35..95616d4f 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -17,17 +17,18 @@ import { } from '@mui/material/styles'; import { ThemeMode } from '@/components/context/ThemeModeContext.tsx'; import { MediaQuery } from '@/lib/ui/MediaQuery.tsx'; +import { AppThemes, getTheme } from '@/lib/ui/AppThemes.ts'; export const SCROLLBAR_WIDTH = 14; -let theme: Theme; -export const getCurrentTheme = () => theme; export const createTheme = ( themeMode: ThemeMode, - direction: Direction = 'ltr', - color: string = '#5b74ef', + appThemeId: AppThemes, pureBlackMode: boolean = false, + direction: Direction = 'ltr', ) => { + const appTheme = getTheme(appThemeId); + const systemMode = MediaQuery.getSystemThemeMode(); const mode = themeMode === ThemeMode.SYSTEM ? systemMode : themeMode; const isDarkMode = mode === ThemeMode.DARK; @@ -37,9 +38,7 @@ export const createTheme = ( direction, palette: { mode, - primary: { - main: color, - }, + ...appTheme.palette, }, }); @@ -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) => { + theme = createTheme(...args); return theme; };