From ba2ce37468962966fe7e8c041e0830f30162688c Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Wed, 4 Sep 2024 23:46:04 +0200 Subject: [PATCH] Add option to select system theme --- public/locales/en.json | 4 ++- src/components/context/AppContext.tsx | 29 ++++++++++++------ .../{DarkTheme.tsx => ThemeModeContext.tsx} | 16 ++++++---- src/lib/ui/MediaQuery.tsx | 18 +++++++++++ src/screens/settings/Appearance.tsx | 30 +++++++++++++++---- src/theme.ts | 10 +++++-- 6 files changed, 83 insertions(+), 24 deletions(-) rename src/components/context/{DarkTheme.tsx => ThemeModeContext.tsx} (50%) diff --git a/public/locales/en.json b/public/locales/en.json index dc032472..0cf522af 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -817,6 +817,8 @@ } }, "appearance": { + "device_theme": "Device theme", + "theme": "Theme", "title": "Appearance" }, "backup": { @@ -912,7 +914,7 @@ } }, "label": { - "dark_theme": "Dark theme", + "theme": "Theme", "hide_library_entries": "Hide entries already in library", "language_description": "Feel free to translate the project on", "manga_item_width": "Manga item width", diff --git a/src/components/context/AppContext.tsx b/src/components/context/AppContext.tsx index 028ea669..92b5c11f 100644 --- a/src/components/context/AppContext.tsx +++ b/src/components/context/AppContext.tsx @@ -7,7 +7,7 @@ */ import { Direction, StyledEngineProvider, ThemeProvider } from '@mui/material/styles'; -import React, { useMemo, useRef } from 'react'; +import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; import { BrowserRouter as Router } from 'react-router-dom'; import { QueryParamProvider } from 'use-query-params'; import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6'; @@ -18,10 +18,11 @@ import { prefixer } from 'stylis'; import rtlPlugin from 'stylis-plugin-rtl'; import { createTheme } from '@/theme'; import { useLocalStorage } from '@/util/useStorage.tsx'; -import { DarkTheme } from '@/components/context/DarkTheme'; +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'; interface Props { children: React.ReactNode; @@ -48,15 +49,22 @@ export const AppContext: React.FC = ({ children }) => { directionRef.current = currentDirection; } - const [darkTheme, setDarkTheme] = useLocalStorage('darkTheme', true); + const [systemThemeMode, setSystemThemeMode] = useState(MediaQuery.getSystemThemeMode()); + useLayoutEffect(() => { + const unsubscribe = MediaQuery.listenToSystemThemeChange(setSystemThemeMode); + + return () => unsubscribe(); + }, []); + + const [themeMode, setThemeMode] = useLocalStorage('themeMode', ThemeMode.SYSTEM); const [activeDevice, setActiveDeviceContext] = useLocalStorage('activeDevice', DEFAULT_DEVICE); const darkThemeContext = useMemo( () => ({ - darkTheme, - setDarkTheme, + themeMode, + setThemeMode, }), - [darkTheme], + [themeMode], ); const activeDeviceContext = useMemo( @@ -64,7 +72,10 @@ export const AppContext: React.FC = ({ children }) => { [activeDevice], ); - const theme = useMemo(() => createTheme(darkTheme, currentDirection), [darkTheme, currentDirection]); + const theme = useMemo( + () => createTheme(themeMode, currentDirection), + [themeMode, currentDirection, systemThemeMode], + ); setActiveDevice(activeDevice); @@ -73,7 +84,7 @@ export const AppContext: React.FC = ({ children }) => { - + @@ -83,7 +94,7 @@ export const AppContext: React.FC = ({ children }) => { - + diff --git a/src/components/context/DarkTheme.tsx b/src/components/context/ThemeModeContext.tsx similarity index 50% rename from src/components/context/DarkTheme.tsx rename to src/components/context/ThemeModeContext.tsx index 21366697..6f623f37 100644 --- a/src/components/context/DarkTheme.tsx +++ b/src/components/context/ThemeModeContext.tsx @@ -8,12 +8,18 @@ import React from 'react'; +export enum ThemeMode { + SYSTEM = 'system', + DARK = 'dark', + LIGHT = 'light', +} + type ContextType = { - darkTheme: boolean; - setDarkTheme: React.Dispatch>; + themeMode: ThemeMode; + setThemeMode: React.Dispatch>; }; -export const DarkTheme = React.createContext({ - darkTheme: true, - setDarkTheme: (): void => {}, +export const ThemeModeContext = React.createContext({ + themeMode: ThemeMode.SYSTEM, + setThemeMode: (): void => {}, }); diff --git a/src/lib/ui/MediaQuery.tsx b/src/lib/ui/MediaQuery.tsx index 239742e2..3bf382f1 100644 --- a/src/lib/ui/MediaQuery.tsx +++ b/src/lib/ui/MediaQuery.tsx @@ -9,6 +9,7 @@ import useMediaQuery from '@mui/material/useMediaQuery'; import { Breakpoint } from '@mui/material/styles'; import { getCurrentTheme } from '@/theme.ts'; +import { ThemeMode } from '@/components/context/ThemeModeContext.tsx'; export class MediaQuery { static useIsTouchDevice(): boolean { @@ -22,4 +23,21 @@ export class MediaQuery { static useIsMobileWidth(): boolean { return this.useIsBelowWidth('sm'); } + + static getSystemThemeMode(): Exclude { + const prefersDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + return prefersDarkMode ? ThemeMode.DARK : ThemeMode.LIGHT; + } + + static listenToSystemThemeChange(onChange: (themeMode: Exclude) => void): () => void { + const handleSystemThemeModeChange = (e: MediaQueryListEvent) => { + onChange(e.matches ? ThemeMode.DARK : ThemeMode.LIGHT); + }; + + const matchSystemThemeMode = window.matchMedia('(prefers-color-scheme: dark)'); + + matchSystemThemeMode.addEventListener('change', handleSystemThemeModeChange); + + return () => matchSystemThemeMode.removeEventListener('change', handleSystemThemeModeChange); + } } diff --git a/src/screens/settings/Appearance.tsx b/src/screens/settings/Appearance.tsx index 28362b44..2de111f0 100644 --- a/src/screens/settings/Appearance.tsx +++ b/src/screens/settings/Appearance.tsx @@ -11,13 +11,15 @@ import { useContext, useEffect } from 'react'; import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; import ListItemText from '@mui/material/ListItemText'; -import Switch from '@mui/material/Switch'; +import MenuItem from '@mui/material/MenuItem'; +import ListSubheader from '@mui/material/ListSubheader'; import { NavBarContext } from '@/components/context/NavbarContext.tsx'; -import { DarkTheme } from '@/components/context/DarkTheme'; +import { ThemeMode, ThemeModeContext } from '@/components/context/ThemeModeContext.tsx'; +import { Select } from '@/components/atoms/Select.tsx'; export const Appearance = () => { const { t } = useTranslation(); - const { darkTheme, setDarkTheme } = useContext(DarkTheme); + const { themeMode, setThemeMode } = useContext(ThemeModeContext); const { setTitle, setAction } = useContext(NavBarContext); useEffect(() => { @@ -31,10 +33,26 @@ export const Appearance = () => { }, [t]); return ( - + + {t('settings.appearance.theme')} + + } + > - - setDarkTheme(!darkTheme)} /> + + value={themeMode} onChange={(e) => setThemeMode(e.target.value as ThemeMode)}> + + System + + + Dark + + + Light + + ); diff --git a/src/theme.ts b/src/theme.ts index ed13941a..19be1ea5 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -15,23 +15,27 @@ import { responsiveFontSizes, Theme, } from '@mui/material/styles'; +import { ThemeMode } from '@/components/context/ThemeModeContext.tsx'; +import { MediaQuery } from '@/lib/ui/MediaQuery.tsx'; export const SCROLLBAR_WIDTH = 14; let theme: Theme; export const getCurrentTheme = () => theme; export const createTheme = ( - dark?: boolean, + themeMode: ThemeMode, direction: Direction = 'ltr', color: string = '#5b74ef', pureBlackMode: boolean = false, ) => { - const isDarkMode = dark || pureBlackMode; + const systemMode = MediaQuery.getSystemThemeMode(); + const mode = themeMode === ThemeMode.SYSTEM ? systemMode : themeMode; + const isDarkMode = mode === ThemeMode.DARK; const baseTheme = createMuiTheme({ direction, palette: { - mode: isDarkMode ? 'dark' : 'light', + mode, primary: { main: color, },