Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bring back dark mode support with simpler logic #107

Merged
merged 13 commits into from
Mar 3, 2025
38 changes: 6 additions & 32 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { NavBar } from './views/Navigation/NavBar'
import { useColorScheme } from '@mui/joy'
import { Outlet } from 'react-router-dom'
import { UserContext } from './contexts/UserContext'
import { isTokenValid } from './utils/api'
import React from 'react'
import { ThemeMode } from './constants/theme'
import { GetUserProfile } from './api/users'
import { User } from './models/user'
import { useRoot } from './utils/dom'
import { WithNavigate } from './utils/navigation'
import { CssBaseline, CssVarsProvider } from '@mui/joy'

type AppProps = WithNavigate

Expand All @@ -32,37 +30,10 @@ export class App extends React.Component<AppProps, AppState> {
})
}

private applyTheme = (className: string) => {
useRoot().classList.add(className)
}

private setUserProfile = (userProfile: User | null) => {
this.setState({ userProfile })
}

private loadTheme = () => {
const { mode, systemMode } = useColorScheme()
const value: ThemeMode =
JSON.parse(localStorage.getItem('themeMode') ?? '') || mode

switch (value) {
default:
case 'system':
if (systemMode === 'dark') {
this.applyTheme('dark')
}
break

case 'light':
this.applyTheme('light')
break

case 'dark':
this.applyTheme('dark')
break
}
}

componentDidMount(): void {
if (isTokenValid()) {
this.loadUserProfile()
Expand All @@ -77,8 +48,11 @@ export class App extends React.Component<AppProps, AppState> {
return (
<div style={{ minHeight: '100vh' }}>
<UserContext.Provider value={{ userProfile, setUserProfile }}>
<NavBar navigate={navigate} />
<Outlet />
<CssBaseline />
<CssVarsProvider modeStorageKey='themeMode' attribute='data-theme' defaultMode='system' colorSchemeNode={document.body}>
<NavBar navigate={navigate} />
<Outlet />
</CssVarsProvider>
</UserContext.Provider>
</div>
)
Expand Down
37 changes: 35 additions & 2 deletions src/constants/theme.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
export type ThemeMode = 'light' | 'dark' | 'system'
import { applyTheme } from '@/utils/dom'
import { Mode } from '@mui/system/cssVars/useCurrentColorScheme'

export const getNextThemeMode = (currentThemeMode: ThemeMode): ThemeMode => {
export type Theme = 'light' | 'dark'

export const getCurrentThemeMode = (): Mode => {
const mode = localStorage.getItem('themeMode')
if (mode === 'light' || mode === 'dark' || mode === 'system') {
return mode
}

return 'system'
}

export const getNextThemeMode = (currentThemeMode: Mode): Mode => {
switch (currentThemeMode) {
case 'light':
return 'dark'
Expand All @@ -11,3 +23,24 @@ export const getNextThemeMode = (currentThemeMode: ThemeMode): ThemeMode => {
return 'light'
}
}

function prefersDarkMode() {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}

function themeForMode(mode: Mode): Theme {
switch (mode) {
case 'light':
case 'dark':
return mode

case 'system':
default:
return prefersDarkMode() ? 'dark' : 'light'
}
}

export const setThemeMode = (mode: Mode): void => {
localStorage.setItem('themeMode', mode)
applyTheme(themeForMode(mode))
}
17 changes: 0 additions & 17 deletions src/contexts/ThemeContext.tsx

This file was deleted.

6 changes: 6 additions & 0 deletions src/utils/dom.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Theme } from '@/constants/theme';

let root: HTMLDivElement | null = null

export const useRoot = (): HTMLDivElement => {
Expand All @@ -17,3 +19,7 @@ export const setTitle = (title: string): void => {
export const isMobile = (): boolean => {
return window.innerWidth <= 768
}

export const applyTheme = (theme: Theme): void => {
document.body.dataset.theme = theme
}
20 changes: 6 additions & 14 deletions src/views/Navigation/NavBar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { getNextThemeMode } from '@/constants/theme'
import {
MenuRounded,
HomeOutlined,
Expand All @@ -21,7 +20,6 @@ import { ThemeToggleButton } from '../Settings/ThemeToggleButton'
import { NavBarLink } from './NavBarLink'
import { getPathName, NavigationPaths, WithNavigate } from '@/utils/navigation'
import { Logo } from '@/Logo'
import { ThemeContext, ThemeContextState } from '@/contexts/ThemeContext'

type NavBarProps = WithNavigate

Expand Down Expand Up @@ -91,18 +89,12 @@ export class NavBar extends React.Component<NavBarProps, NavBarState> {
fontSize: 24,
}}
/>
<ThemeContext.Consumer>
{({themeMode, setThemeMode}: ThemeContextState) => (
<ThemeToggleButton
themeMode={themeMode}
onThemeModeToggle={() => setThemeMode(getNextThemeMode(themeMode))}
sx={{
position: 'absolute',
right: 10,
}}
/>
)}
</ThemeContext.Consumer>
<ThemeToggleButton
style={{
position: 'absolute',
right: 10,
}}
/>
</Box>
<Drawer
open={this.state.drawerOpen}
Expand Down
10 changes: 1 addition & 9 deletions src/views/Settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { PassowrdChangeModal } from '../Modals/Inputs/PasswordChangeModal'
import { APITokenSettings } from './APITokenSettings'
import { NotificationSetting } from '../Notifications/NotificationSettings'
import { ThemeToggle } from './ThemeToggle'
import { ThemeContext } from '@/contexts/ThemeContext'

export class Settings extends React.Component {
private changePasswordModal = React.createRef<PassowrdChangeModal>()
Expand Down Expand Up @@ -60,14 +59,7 @@ export class Settings extends React.Component {
<Typography level='h3'>Theme preferences</Typography>
<Divider />

<ThemeContext.Consumer>
{storedState => (
<ThemeToggle
themeMode={storedState.themeMode}
onThemeModeToggle={storedState.setThemeMode}
/>
)}
</ThemeContext.Consumer>
<ThemeToggle />
</Box>
</Container>
)
Expand Down
31 changes: 22 additions & 9 deletions src/views/Settings/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,42 @@
import { ThemeMode } from '@/constants/theme'
import { Mode } from '@mui/system/cssVars/useCurrentColorScheme'
import {
LightModeOutlined,
DarkModeOutlined,
LaptopOutlined,
} from '@mui/icons-material'
import { ToggleButtonGroup, Button, Box } from '@mui/joy'
import React from 'react'
import { getCurrentThemeMode, setThemeMode } from '@/constants/theme'

interface ThemeToggleProps {
themeMode: ThemeMode
onThemeModeToggle: (newTheme: ThemeMode) => void
type ThemeToggleProps = object
interface ThemeToggleState {
mode: Mode
}

export class ThemeToggle extends React.Component<ThemeToggleProps> {
private onChange = (_: React.MouseEvent, newThemeMode: ThemeMode | null) => {
export class ThemeToggle extends React.Component<ThemeToggleProps, ThemeToggleState> {
constructor(props: ThemeToggleProps) {
super(props)

this.state = {
mode: getCurrentThemeMode(),
}
}

private onChange = (_: React.MouseEvent, newThemeMode: Mode | null) => {
if (!newThemeMode) {
return
}

this.props.onThemeModeToggle(newThemeMode)
setThemeMode(newThemeMode)

this.setState({
mode: newThemeMode,
})
}

render(): React.ReactNode {
const ELEMENTID = 'select-theme-mode'
const { themeMode } = this.props
const { mode } = this.state

return (
<Box sx={{
Expand All @@ -32,7 +45,7 @@ export class ThemeToggle extends React.Component<ThemeToggleProps> {
<ToggleButtonGroup
id={ELEMENTID}
variant='outlined'
value={themeMode}
value={mode}
onChange={this.onChange}
>
<Button
Expand Down
45 changes: 29 additions & 16 deletions src/views/Settings/ThemeToggleButton.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,55 @@
import { ThemeMode } from '@/constants/theme'
import { Mode } from '@mui/system/cssVars/useCurrentColorScheme'
import {
DarkModeOutlined,
BrightnessAuto,
LightModeOutlined,
} from '@mui/icons-material'
import { FormControl, IconButton } from '@mui/joy'
import { SxProps } from '@mui/material'
import { IconButton } from '@mui/joy'
import React from 'react'
import { getCurrentThemeMode, getNextThemeMode, setThemeMode } from '@/constants/theme'

interface ThemeToggleButtonProps {
sx: SxProps
themeMode: ThemeMode
onThemeModeToggle: () => void
style: React.CSSProperties
}

export class ThemeToggleButton extends React.Component<ThemeToggleButtonProps> {
private onClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault()
e.stopPropagation()
interface ThemeToggleButtonState {
mode: Mode
}

export class ThemeToggleButton extends React.Component<ThemeToggleButtonProps, ThemeToggleButtonState> {
constructor(props: ThemeToggleButtonProps) {
super(props)

this.state = {
mode: getCurrentThemeMode(),
}
}

private onClick = () => {
const nextMode = getNextThemeMode(this.state.mode)
setThemeMode(nextMode)

this.props.onThemeModeToggle()
this.setState({
mode: nextMode,
})
}

render(): React.ReactNode {
const { themeMode } = this.props
const { style } = this.props
const { mode } = this.state

return (
<FormControl sx={this.props.sx}>
<div style={style}>
<IconButton onClick={this.onClick}>
{themeMode === 'light' ? (
{mode === 'light' ? (
<DarkModeOutlined />
) : themeMode === 'dark' ? (
) : mode === 'dark' ? (
<BrightnessAuto />
) : (
<LightModeOutlined />
)}
</IconButton>
</FormControl>
</div>
)
}
}